diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 35958d604..645c33b2e 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -91,7 +91,7 @@ class CollectionSectionHeader extends StatelessWidget { } 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; } } diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 14bcf5433..341f969e5 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; @@ -25,17 +26,42 @@ class SectionHeader extends StatelessWidget { }); 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; @override 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( alignment: AlignmentDirectional.centerStart, + margin: margin, + child: child, + ); + } + + Widget _buildContent(BuildContext context) { + return Container( padding: padding, constraints: BoxConstraints(minHeight: leadingSize.height), child: GestureDetector( - onTap: selectable ? () => _toggleSectionSelection(context) : null, + onTap: _onTap(context), onLongPress: selectable ? () { final selection = context.read>(); @@ -63,7 +89,7 @@ class SectionHeader extends StatelessWidget { child: leading, ) : null, - onPressed: selectable ? () => _toggleSectionSelection(context) : null, + onPressed: _onTap(context), ), ), TextSpan( @@ -85,6 +111,8 @@ class SectionHeader extends StatelessWidget { ); } + VoidCallback? _onTap(BuildContext context) => selectable ? () => _toggleSectionSelection(context) : null; + List _getSectionEntries(BuildContext context) => context.read>().sections[sectionKey] ?? []; void _toggleSectionSelection(BuildContext context) { @@ -107,7 +135,7 @@ class SectionHeader extends StatelessWidget { bool hasTrailing = false, }) { final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final maxContentWidth = maxWidth - SectionHeader.padding.horizontal; + final maxContentWidth = maxWidth - (SectionHeader.padding.horizontal + SectionHeader.margin.horizontal); final para = RenderParagraph( TextSpan( children: [ @@ -159,27 +187,31 @@ class _SectionSelectableLeading extends StatelessWidget { ) : _buildBrowsing(context); - return AnimatedSwitcher( - duration: Durations.sectionHeaderAnimation, - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - transitionBuilder: (child, animation) { - Widget transition = ScaleTransition( - scale: animation, - child: child, - ); - if (browsingBuilder == null) { - // when switching with a header that has no icon, - // we also transition the size for a smooth push to the text - transition = SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - child: transition, + return FocusTraversalGroup( + descendantsAreFocusable: false, + descendantsAreTraversable: false, + child: AnimatedSwitcher( + duration: Durations.sectionHeaderAnimation, + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + Widget transition = ScaleTransition( + scale: animation, + child: child, ); - } - return transition; - }, - child: child, + if (browsingBuilder == null) { + // when switching with a header that has no icon, + // we also transition the size for a smooth push to the text + transition = SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: transition, + ); + } + return transition; + }, + child: child, + ), ); } diff --git a/lib/widgets/filter_grids/common/section_header.dart b/lib/widgets/filter_grids/common/section_header.dart index 977eb5bb5..b275f8549 100644 --- a/lib/widgets/filter_grids/common/section_header.dart +++ b/lib/widgets/filter_grids/common/section_header.dart @@ -24,6 +24,6 @@ class FilterChipSectionHeader extends StatelessWidget { static double getPreferredHeight(BuildContext 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; } } diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart index 1a916854c..4d202ef9d 100644 --- a/lib/widgets/navigation/tv_rail.dart +++ b/lib/widgets/navigation/tv_rail.dart @@ -85,6 +85,7 @@ class _TvRailState extends State { children: [ const SizedBox(height: 8), header, + const SizedBox(height: 4), Expanded( child: LayoutBuilder( builder: (context, constraints) { diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 7d6761326..56336df40 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -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/extensions/build_context.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/navigation/tv_rail.dart'; import 'package:aves/widgets/settings/accessibility/accessibility.dart'; @@ -48,7 +47,7 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State with FeedbackMixin { final ValueNotifier _expandedNotifier = ValueNotifier(null); - Future Function(BuildContext)?>>? _tvSettingsLoader; + final ValueNotifier _tvSelectedIndexNotifier = ValueNotifier(0); static final List sections = [ NavigationSection(), @@ -62,11 +61,14 @@ class _SettingsPageState extends State with FeedbackMixin { ]; @override - Widget build(BuildContext context) { - if (device.isTelevision) { - _initTvSettings(context); - } + void dispose() { + _expandedNotifier.dispose(); + _tvSelectedIndexNotifier.dispose(); + super.dispose(); + } + @override + Widget build(BuildContext context) { final appBarTitle = Text(context.l10n.settingsPageTitle); if (device.isTelevision) { @@ -75,23 +77,53 @@ class _SettingsPageState extends State with FeedbackMixin { children: [ const TvRail(), Expanded( - child: FutureBuilder Function(BuildContext)?>>( - future: _tvSettingsLoader, - builder: (context, snapshot) { - final loaders = snapshot.data; - if (loaders == null) return const SizedBox(); - - return _buildListView( - children: [ - AppBar( - automaticallyImplyLeading: false, - title: appBarTitle, - elevation: 0, - ), - ...loaders.whereNotNull().expand((builder) => builder(context)), - ], - ); - }, + child: Column( + children: [ + const SizedBox(height: 8), + AppBar( + automaticallyImplyLeading: false, + title: appBarTitle, + elevation: 0, + ), + Expanded( + child: ValueListenableBuilder( + 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,8 +168,10 @@ class _SettingsPageState extends State with FeedbackMixin { body: GestureAreaProtectorStack( child: SafeArea( bottom: false, - child: _buildListView( - children: sections.map((v) => v.build(context, _expandedNotifier)).toList(), + child: AnimationLimiter( + child: _SettingsListView( + children: sections.map((v) => v.build(context, _expandedNotifier)).toList(), + ), ), ), ), @@ -145,67 +179,6 @@ class _SettingsPageState extends State with FeedbackMixin { } } - void _initTvSettings(BuildContext context) { - _tvSettingsLoader ??= Future.wait(sections.map((section) async { - final tiles = await section.tiles(context); - return (context) { - return [ - 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 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( - selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), - builder: (context, mqPaddingBottom, child) { - final durations = context.watch(); - 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 int exportVersion = 1; @@ -304,3 +277,67 @@ class _SettingsPageState extends State with FeedbackMixin { ); } } + +class _SettingsListView extends StatelessWidget { + final List 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( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + final durations = context.watch(); + 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> loader; + + const _SettingsSectionBody({required this.loader}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + 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(), + ); + }, + ); + } +}