#437 tv: grid headers, settings rail

This commit is contained in:
Thibault Deckers 2022-12-14 12:30:04 +01:00
parent 52c59e2cc5
commit 737dac8da1
5 changed files with 182 additions and 112 deletions

View file

@ -91,7 +91,7 @@ class CollectionSectionHeader extends StatelessWidget {
} }
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
headerExtent = max(headerExtent, SectionHeader.leadingSize.height * textScaleFactor) + SectionHeader.padding.vertical; headerExtent = max(headerExtent, SectionHeader.leadingSize.height * textScaleFactor) + SectionHeader.padding.vertical + SectionHeader.margin.vertical;
return headerExtent; return headerExtent;
} }
} }

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
@ -25,17 +26,42 @@ class SectionHeader<T> extends StatelessWidget {
}); });
static const leadingSize = Size(48, 32); static const leadingSize = Size(48, 32);
static const padding = EdgeInsets.all(16); static const margin = EdgeInsets.symmetric(vertical: 0, horizontal: 8);
static const padding = EdgeInsets.symmetric(vertical: 16, horizontal: 8);
static const widgetSpanAlignment = PlaceholderAlignment.middle; static const widgetSpanAlignment = PlaceholderAlignment.middle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = _buildContent(context);
if (device.isTelevision) {
final colors = Theme.of(context).colorScheme;
child = Material(
type: MaterialType.transparency,
child: InkResponse(
onTap: _onTap(context),
onHover: (_) {},
highlightShape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(123)),
containedInkWell: true,
splashColor: colors.primary.withOpacity(0.12),
hoverColor: colors.primary.withOpacity(0.04),
child: child,
),
);
}
return Container( return Container(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
margin: margin,
child: child,
);
}
Widget _buildContent(BuildContext context) {
return Container(
padding: padding, padding: padding,
constraints: BoxConstraints(minHeight: leadingSize.height), constraints: BoxConstraints(minHeight: leadingSize.height),
child: GestureDetector( child: GestureDetector(
onTap: selectable ? () => _toggleSectionSelection(context) : null, onTap: _onTap(context),
onLongPress: selectable onLongPress: selectable
? () { ? () {
final selection = context.read<Selection<T>>(); final selection = context.read<Selection<T>>();
@ -63,7 +89,7 @@ class SectionHeader<T> extends StatelessWidget {
child: leading, child: leading,
) )
: null, : null,
onPressed: selectable ? () => _toggleSectionSelection(context) : null, onPressed: _onTap(context),
), ),
), ),
TextSpan( TextSpan(
@ -85,6 +111,8 @@ class SectionHeader<T> extends StatelessWidget {
); );
} }
VoidCallback? _onTap(BuildContext context) => selectable ? () => _toggleSectionSelection(context) : null;
List<T> _getSectionEntries(BuildContext context) => context.read<SectionedListLayout<T>>().sections[sectionKey] ?? []; List<T> _getSectionEntries(BuildContext context) => context.read<SectionedListLayout<T>>().sections[sectionKey] ?? [];
void _toggleSectionSelection(BuildContext context) { void _toggleSectionSelection(BuildContext context) {
@ -107,7 +135,7 @@ class SectionHeader<T> extends StatelessWidget {
bool hasTrailing = false, bool hasTrailing = false,
}) { }) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final maxContentWidth = maxWidth - SectionHeader.padding.horizontal; final maxContentWidth = maxWidth - (SectionHeader.padding.horizontal + SectionHeader.margin.horizontal);
final para = RenderParagraph( final para = RenderParagraph(
TextSpan( TextSpan(
children: [ children: [
@ -159,7 +187,10 @@ class _SectionSelectableLeading<T> extends StatelessWidget {
) )
: _buildBrowsing(context); : _buildBrowsing(context);
return AnimatedSwitcher( return FocusTraversalGroup(
descendantsAreFocusable: false,
descendantsAreTraversable: false,
child: AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation, duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut, switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut, switchOutCurve: Curves.easeInOut,
@ -180,6 +211,7 @@ class _SectionSelectableLeading<T> extends StatelessWidget {
return transition; return transition;
}, },
child: child, child: child,
),
); );
} }

View file

@ -24,6 +24,6 @@ class FilterChipSectionHeader<T> extends StatelessWidget {
static double getPreferredHeight(BuildContext context) { static double getPreferredHeight(BuildContext context) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
return SectionHeader.leadingSize.height * textScaleFactor + SectionHeader.padding.vertical; return SectionHeader.leadingSize.height * textScaleFactor + SectionHeader.padding.vertical + SectionHeader.margin.vertical;
} }
} }

View file

@ -85,6 +85,7 @@ class _TvRailState extends State<TvRail> {
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
header, header,
const SizedBox(height: 4),
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {

View file

@ -15,7 +15,6 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/highlight_title.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/settings/accessibility/accessibility.dart'; import 'package:aves/widgets/settings/accessibility/accessibility.dart';
@ -48,7 +47,7 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> with FeedbackMixin { class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null); final ValueNotifier<String?> _expandedNotifier = ValueNotifier(null);
Future<List<List<Widget> Function(BuildContext)?>>? _tvSettingsLoader; final ValueNotifier<int> _tvSelectedIndexNotifier = ValueNotifier(0);
static final List<SettingsSection> sections = [ static final List<SettingsSection> sections = [
NavigationSection(), NavigationSection(),
@ -62,11 +61,14 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
]; ];
@override @override
Widget build(BuildContext context) { void dispose() {
if (device.isTelevision) { _expandedNotifier.dispose();
_initTvSettings(context); _tvSelectedIndexNotifier.dispose();
super.dispose();
} }
@override
Widget build(BuildContext context) {
final appBarTitle = Text(context.l10n.settingsPageTitle); final appBarTitle = Text(context.l10n.settingsPageTitle);
if (device.isTelevision) { if (device.isTelevision) {
@ -75,23 +77,53 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
children: [ children: [
const TvRail(), const TvRail(),
Expanded( Expanded(
child: FutureBuilder<List<List<Widget> Function(BuildContext)?>>( child: Column(
future: _tvSettingsLoader,
builder: (context, snapshot) {
final loaders = snapshot.data;
if (loaders == null) return const SizedBox();
return _buildListView(
children: [ children: [
const SizedBox(height: 8),
AppBar( AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: appBarTitle, title: appBarTitle,
elevation: 0, elevation: 0,
), ),
...loaders.whereNotNull().expand((builder) => builder(context)), Expanded(
child: ValueListenableBuilder<int>(
valueListenable: _tvSelectedIndexNotifier,
builder: (context, selectedIndex, child) {
final rail = NavigationRail(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
extended: true,
destinations: sections
.map((section) => NavigationRailDestination(
icon: section.icon(context),
label: Text(section.title(context)),
))
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index,
);
return LayoutBuilder(
builder: (context, constraints) {
return Row(
children: [
SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(child: rail),
),
),
Expanded(
child: _SettingsSectionBody(
loader: Future.value(sections[selectedIndex].tiles(context)),
),
),
], ],
); );
}, },
);
},
),
),
],
), ),
), ),
], ],
@ -136,75 +168,16 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
body: GestureAreaProtectorStack( body: GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: _buildListView( child: AnimationLimiter(
child: _SettingsListView(
children: sections.map((v) => v.build(context, _expandedNotifier)).toList(), children: sections.map((v) => v.build(context, _expandedNotifier)).toList(),
), ),
), ),
), ),
);
}
}
void _initTvSettings(BuildContext context) {
_tvSettingsLoader ??= Future.wait(sections.map((section) async {
final tiles = await section.tiles(context);
return (context) {
return <Widget>[
Padding(
// match header layout in Settings page
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 13),
child: Row(
children: [
section.icon(context),
const SizedBox(width: 8),
Expanded(
child: HighlightTitle(
title: section.title(context),
showHighlight: false,
),
),
],
),
),
...tiles.map((v) => v.build(context)),
];
};
}));
}
Widget _buildListView({required List<Widget> children}) {
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
// dense style font for tile subtitles, without modifying title font
bodyMedium: const TextStyle(fontSize: 12),
),
),
child: AnimationLimiter(
child: Selector<MediaQueryData, double>(
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
builder: (context, mqPaddingBottom, child) {
final durations = context.watch<DurationsData>();
return ListView(
padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom),
children: AnimationConfiguration.toStaggeredList(
duration: durations.staggeredAnimation,
delay: durations.staggeredAnimationDelay * timeDilation,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
children: children,
),
);
},
),
), ),
); );
} }
}
static const String exportVersionKey = 'version'; static const String exportVersionKey = 'version';
static const int exportVersion = 1; static const int exportVersion = 1;
@ -304,3 +277,67 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
); );
} }
} }
class _SettingsListView extends StatelessWidget {
final List<Widget> children;
const _SettingsListView({
super.key,
required this.children,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
textTheme: theme.textTheme.copyWith(
// dense style font for tile subtitles, without modifying title font
bodyMedium: const TextStyle(fontSize: 12),
),
),
child: Selector<MediaQueryData, double>(
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
builder: (context, mqPaddingBottom, child) {
final durations = context.watch<DurationsData>();
return ListView(
padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom),
children: AnimationConfiguration.toStaggeredList(
duration: durations.staggeredAnimation,
delay: durations.staggeredAnimationDelay * timeDilation,
childAnimationBuilder: (child) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: child,
),
),
children: children,
),
);
},
),
);
}
}
class _SettingsSectionBody extends StatelessWidget {
final Future<List<SettingsTile>> loader;
const _SettingsSectionBody({required this.loader});
@override
Widget build(BuildContext context) {
return FutureBuilder<List<SettingsTile>>(
future: loader,
builder: (context, snapshot) {
final tiles = snapshot.data;
if (tiles == null) return const SizedBox();
return _SettingsListView(
key: ValueKey(loader),
children: tiles.map((v) => v.build(context)).toList(),
);
},
);
}
}