diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 3b5a289d2..92066f7f2 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -8,6 +8,7 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class AboutPage extends StatelessWidget { static const routeName = '/about'; @@ -47,7 +48,9 @@ class AboutPage extends StatelessWidget { return Scaffold( body: Row( children: [ - const TvRail(), + TvRail( + controller: context.read(), + ), Expanded(child: body), ], ), diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index d3b81762b..011c848e7 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -35,6 +35,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/navigation/tv_page_transitions.dart'; +import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:equatable/equatable.dart'; @@ -126,6 +127,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { late final Future _appSetup; late final Future _shouldUseBoldFontLoader; late final Future _dynamicColorPaletteLoader; + final TvRailController _tvRailController = TvRailController(); final CollectionSource _mediaStoreSource = MediaStoreSource(); final Debouncer _mediaStoreChangeDebouncer = Debouncer(delay: Durations.mediaContentChangeDebounceDelay); final Set _changedUris = {}; @@ -173,109 +175,112 @@ class _AvesAppState extends State with WidgetsBindingObserver { value: appModeNotifier, child: Provider.value( value: _mediaStoreSource, - child: DurationsProvider( - child: HighlightInfoProvider( - child: OverlaySupport( - child: FutureBuilder( - future: _appSetup, - builder: (context, snapshot) { - final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done; - if (initialized) { - AvesApp.showSystemUI(); - } - final home = initialized - ? _getFirstPage() - : Scaffold( - body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), - ); - return Selector>( - selector: (context, s) => Tuple4( - s.locale, - s.initialized ? s.accessibilityAnimations.animate : true, - s.initialized ? s.themeBrightness : SettingsDefaults.themeBrightness, - s.initialized ? s.enableDynamicColor : SettingsDefaults.enableDynamicColor, - ), - builder: (context, s, child) { - final settingsLocale = s.item1; - final areAnimationsEnabled = s.item2; - final themeBrightness = s.item3; - final enableDynamicColor = s.item4; - - Constants.updateStylesForLocale(settings.appliedLocale); - - return FutureBuilder( - future: _dynamicColorPaletteLoader, - builder: (context, snapshot) { - const defaultAccent = Themes.defaultAccent; - Color lightAccent = defaultAccent, darkAccent = defaultAccent; - if (enableDynamicColor) { - // `DynamicColorBuilder` from package `dynamic_color` provides light/dark - // palettes with a primary color from tones too dark/light (40/80), - // so we derive the color with adjusted tones (60/70) - final tonalPalette = snapshot.data?.primary; - lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value); - darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value); - } - final lightTheme = Themes.lightTheme(lightAccent, initialized); - final darkTheme = themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized); - return FutureBuilder( - future: _shouldUseBoldFontLoader, - builder: (context, snapshot) { - // Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery` - // but we need to also check the non-standard Samsung field `bf` representing the bold font toggle - final shouldUseBoldFont = snapshot.data ?? false; - return Shortcuts( - shortcuts: { - // handle Android TV remote `select` button - LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), - }, - child: MaterialApp( - navigatorKey: AvesApp.navigatorKey, - home: home, - navigatorObservers: _navigatorObservers, - builder: (context, child) { - if (initialized) { - WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context)); - } - return MediaQuery( - data: MediaQuery.of(context).copyWith(boldText: shouldUseBoldFont), - child: AvesColorsProvider( - child: ValueListenableBuilder( - valueListenable: _pageTransitionsBuilderNotifier, - builder: (context, pageTransitionsBuilder, child) { - return Theme( - data: Theme.of(context).copyWith( - pageTransitionsTheme: areAnimationsEnabled - ? PageTransitionsTheme(builders: {TargetPlatform.android: pageTransitionsBuilder}) - // strip page transitions used by `MaterialPageRoute` - : const DirectPageTransitionsTheme(), - ), - child: MediaQueryDataProvider(child: child!), - ); - }, - child: child, - ), - ), - ); - }, - onGenerateTitle: (context) => context.l10n.appName, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: themeBrightness.appThemeMode, - locale: settingsLocale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AvesApp.supportedLocales, - // TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906 - scrollBehavior: StretchMaterialScrollBehavior(), - ), - ); - }, + child: Provider.value( + value: _tvRailController, + child: DurationsProvider( + child: HighlightInfoProvider( + child: OverlaySupport( + child: FutureBuilder( + future: _appSetup, + builder: (context, snapshot) { + final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done; + if (initialized) { + AvesApp.showSystemUI(); + } + final home = initialized + ? _getFirstPage() + : Scaffold( + body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), ); - }, - ); - }, - ); - }, + return Selector>( + selector: (context, s) => Tuple4( + s.locale, + s.initialized ? s.accessibilityAnimations.animate : true, + s.initialized ? s.themeBrightness : SettingsDefaults.themeBrightness, + s.initialized ? s.enableDynamicColor : SettingsDefaults.enableDynamicColor, + ), + builder: (context, s, child) { + final settingsLocale = s.item1; + final areAnimationsEnabled = s.item2; + final themeBrightness = s.item3; + final enableDynamicColor = s.item4; + + Constants.updateStylesForLocale(settings.appliedLocale); + + return FutureBuilder( + future: _dynamicColorPaletteLoader, + builder: (context, snapshot) { + const defaultAccent = Themes.defaultAccent; + Color lightAccent = defaultAccent, darkAccent = defaultAccent; + if (enableDynamicColor) { + // `DynamicColorBuilder` from package `dynamic_color` provides light/dark + // palettes with a primary color from tones too dark/light (40/80), + // so we derive the color with adjusted tones (60/70) + final tonalPalette = snapshot.data?.primary; + lightAccent = Color(tonalPalette?.get(60) ?? defaultAccent.value); + darkAccent = Color(tonalPalette?.get(70) ?? defaultAccent.value); + } + final lightTheme = Themes.lightTheme(lightAccent, initialized); + final darkTheme = themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized); + return FutureBuilder( + future: _shouldUseBoldFontLoader, + builder: (context, snapshot) { + // Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery` + // but we need to also check the non-standard Samsung field `bf` representing the bold font toggle + final shouldUseBoldFont = snapshot.data ?? false; + return Shortcuts( + shortcuts: { + // handle Android TV remote `select` button + LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), + }, + child: MaterialApp( + navigatorKey: AvesApp.navigatorKey, + home: home, + navigatorObservers: _navigatorObservers, + builder: (context, child) { + if (initialized) { + WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context)); + } + return MediaQuery( + data: MediaQuery.of(context).copyWith(boldText: shouldUseBoldFont), + child: AvesColorsProvider( + child: ValueListenableBuilder( + valueListenable: _pageTransitionsBuilderNotifier, + builder: (context, pageTransitionsBuilder, child) { + return Theme( + data: Theme.of(context).copyWith( + pageTransitionsTheme: areAnimationsEnabled + ? PageTransitionsTheme(builders: {TargetPlatform.android: pageTransitionsBuilder}) + // strip page transitions used by `MaterialPageRoute` + : const DirectPageTransitionsTheme(), + ), + child: MediaQueryDataProvider(child: child!), + ); + }, + child: child, + ), + ), + ); + }, + onGenerateTitle: (context) => context.l10n.appName, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeBrightness.appThemeMode, + locale: settingsLocale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AvesApp.supportedLocales, + // TODO TLAD remove custom scroll behavior when this is fixed: https://github.com/flutter/flutter/issues/82906 + scrollBehavior: StretchMaterialScrollBehavior(), + ), + ); + }, + ); + }, + ); + }, + ); + }, + ), ), ), ), diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 912d63ef3..b1fd6e2a4 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -121,7 +121,10 @@ class _CollectionPageState extends State { return Scaffold( body: Row( children: [ - TvRail(currentCollection: _collection), + TvRail( + controller: context.read(), + currentCollection: _collection, + ), Expanded(child: body), ], ), diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index e3aceba6e..8f9630514 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -128,7 +128,9 @@ class FilterGridPage extends StatelessWidget { return Scaffold( body: Row( children: [ - const TvRail(), + TvRail( + controller: context.read(), + ), Expanded(child: body), ], ), diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart index 4d202ef9d..300032f8b 100644 --- a/lib/widgets/navigation/tv_rail.dart +++ b/lib/widgets/navigation/tv_rail.dart @@ -20,12 +20,19 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +class TvRailController { + int? focusedIndex; + double offset = 0; +} + class TvRail extends StatefulWidget { // collection loaded in the `CollectionPage`, if any final CollectionLens? currentCollection; + final TvRailController controller; const TvRail({ super.key, + required this.controller, this.currentCollection, }); @@ -34,10 +41,41 @@ class TvRail extends StatefulWidget { } class _TvRailState extends State { - final _scrollController = ScrollController(); + late final ScrollController _scrollController; + final FocusNode _focusNode = FocusNode(); + + TvRailController get controller => widget.controller; CollectionLens? get currentCollection => widget.currentCollection; + @override + void initState() { + super.initState(); + + _scrollController = ScrollController(initialScrollOffset: controller.offset); + _scrollController.addListener(_onScrollChanged); + + final focusedIndex = controller.focusedIndex; + if (focusedIndex != null) { + controller.focusedIndex = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + final nodes = _focusNode.children.toList(); + debugPrint('TLAD focusedIndex=$focusedIndex < nodes.length=${nodes.length}'); + if (focusedIndex < nodes.length) { + nodes[focusedIndex].requestFocus(); + } + }); + } + } + + @override + void dispose() { + _scrollController.removeListener(_onScrollChanged); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final header = Row( @@ -64,21 +102,28 @@ class _TvRailState extends State { ...[ SettingsPage.routeName, AboutPage.routeName, + if (!kReleaseMode) AppDebugPage.routeName, ].map(_routeNavEntry), - if (!kReleaseMode) _routeNavEntry(AppDebugPage.routeName), ]; - final rail = NavigationRail( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - extended: true, - destinations: navEntries - .map((v) => NavigationRailDestination( - icon: v.icon, - label: v.label, - )) - .toList(), - selectedIndex: max(0, navEntries.indexWhere(((v) => v.isSelected))), - onDestinationSelected: (index) => navEntries[index].onSelection(), + final rail = Focus( + focusNode: _focusNode, + skipTraversal: true, + child: NavigationRail( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + extended: true, + destinations: navEntries + .map((v) => NavigationRailDestination( + icon: v.icon, + label: v.label, + )) + .toList(), + selectedIndex: max(0, navEntries.indexWhere(((v) => v.isSelected))), + onDestinationSelected: (index) { + controller.focusedIndex = index; + navEntries[index].onSelection(); + }, + ), ); return Column( @@ -177,6 +222,8 @@ class _TvRailState extends State { (route) => false, ); } + + void _onScrollChanged() => controller.offset = _scrollController.offset; } @immutable diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart index 25c624950..a6b408101 100644 --- a/lib/widgets/settings/privacy/file_picker/crumb_line.dart +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -18,7 +18,7 @@ class CrumbLine extends StatefulWidget { } class _CrumbLineState extends State { - final ScrollController _controller = ScrollController(); + final ScrollController _scrollController = ScrollController(); VolumeRelativeDirectory get directory => widget.directory; @@ -28,8 +28,8 @@ class _CrumbLineState extends State { if (oldWidget.directory.relativeDir.length < widget.directory.relativeDir.length) { // scroll to show last crumb WidgetsBinding.instance.addPostFrameCallback((_) { - final extent = _controller.position.maxScrollExtent; - _controller.animateTo( + final extent = _scrollController.position.maxScrollExtent; + _scrollController.animateTo( extent, duration: const Duration(milliseconds: 500), curve: Curves.easeOutQuad, @@ -53,7 +53,7 @@ class _CrumbLineState extends State { ), child: ListView.builder( scrollDirection: Axis.horizontal, - controller: _controller, + controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 8), itemBuilder: (context, index) { Widget _buildText(String text) => Padding( diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 56336df40..d16bb526f 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -75,7 +75,9 @@ class _SettingsPageState extends State with FeedbackMixin { return Scaffold( body: Row( children: [ - const TvRail(), + TvRail( + controller: context.read(), + ), Expanded( child: Column( children: [