diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b9ea0a1..97e6a52c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. - Map: edit cluster location - Accessibility: optional alternative to pinch-to-zoom thumbnails - Lithuanian translation (thanks Gediminas Murauskas) -- Norsk (Bokmål) translation (thanks Allan Nordhøy) +- Norwegian (Bokmål) translation (thanks Allan Nordhøy) - Chinese (Traditional) translation (thanks pemibe) - Ukrainian translation (thanks Olexandr Mazur) diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 693742ccc..aa747b127 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -9,7 +9,6 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/mime.dart'; -import 'package:aves/model/filters/recent.dart'; import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/map_style.dart'; @@ -250,7 +249,6 @@ class Settings extends ChangeNotifier { null, MimeFilter.video, FavouriteFilter.instance, - RecentlyAddedFilter.instance, ]; drawerPageBookmarks = [ AlbumListPage.routeName, diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 8f97fdde3..a4d1c18ef 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -5,7 +5,8 @@ import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; import 'package:aves/widgets/about/translators.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:flutter/material.dart'; @@ -47,7 +48,8 @@ class AboutPage extends StatelessWidget { if (device.isTelevision) { return Scaffold( - body: TvPopScope( + body: AvesPopScope( + handlers: const [TvNavigationPopHandler.pop], child: Row( children: [ TvRail( diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 55f89c96b..a7ff2eae7 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -140,7 +140,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.0.0) final ValueNotifier _pageTransitionsBuilderNotifier = ValueNotifier(const FadeUpwardsPageTransitionsBuilder()); - final ValueNotifier _navigationModeNotifier = ValueNotifier(NavigationMode.traditional); + final ValueNotifier _tvMediaQueryModifierNotifier = ValueNotifier(null); final ValueNotifier _appModeNotifier = ValueNotifier(AppMode.main); // observers are not registered when using the same list object with different items @@ -170,7 +170,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { @override void dispose() { _pageTransitionsBuilderNotifier.dispose(); - _navigationModeNotifier.dispose(); + _tvMediaQueryModifierNotifier.dispose(); _appModeNotifier.dispose(); _subscriptions ..forEach((sub) => sub.cancel()) @@ -294,14 +294,14 @@ class _AvesAppState extends State with WidgetsBindingObserver { // 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 ValueListenableBuilder( - valueListenable: _navigationModeNotifier, - builder: (context, navigationMode, child) { + final mq = MediaQuery.of(context).copyWith( + boldText: shouldUseBoldFont, + ); + return ValueListenableBuilder( + valueListenable: _tvMediaQueryModifierNotifier, + builder: (context, modifier, child) { return MediaQuery( - data: MediaQuery.of(context).copyWith( - boldText: shouldUseBoldFont, - navigationMode: navigationMode, - ), + data: modifier?.call(mq) ?? mq, child: AvesColorsProvider( child: ValueListenableBuilder( valueListenable: _pageTransitionsBuilderNotifier, @@ -409,7 +409,10 @@ class _AvesAppState extends State with WidgetsBindingObserver { await device.init(); if (device.isTelevision) { _pageTransitionsBuilderNotifier.value = const TvPageTransitionsBuilder(); - _navigationModeNotifier.value = NavigationMode.directional; + _tvMediaQueryModifierNotifier.value = (mq) => mq.copyWith( + textScaleFactor: 1.1, + navigationMode: NavigationMode.directional, + ); } await mobileServices.init(); await settings.init(monitorPlatformSettings: true); @@ -523,3 +526,5 @@ class StretchMaterialScrollBehavior extends MaterialScrollBehavior { ); } } + +typedef TvMediaQueryModifier = MediaQueryData Function(MediaQueryData); diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 3b5cd6808..ece27a063 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -16,8 +16,9 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_fab.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; @@ -26,7 +27,6 @@ import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -52,6 +52,7 @@ class _CollectionPageState extends State { final List _subscriptions = []; late CollectionLens _collection; final StreamController _draggableScrollBarEventStreamController = StreamController.broadcast(); + final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void initState() { @@ -76,6 +77,7 @@ class _CollectionPageState extends State { ..forEach((sub) => sub.cancel()) ..clear(); _collection.dispose(); + _doubleBackPopHandler.dispose(); super.dispose(); } @@ -89,17 +91,21 @@ class _CollectionPageState extends State { final body = QueryProvider( initialQuery: liveFilter?.query, child: Builder( - builder: (context) => WillPopScope( - onWillPop: () { - final selection = context.read>(); - if (selection.isSelecting) { - selection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: const DoubleBackPopScope( - child: GestureAreaProtectorStack( + builder: (context) { + return AvesPopScope( + handlers: [ + (context) { + final selection = context.read>(); + if (selection.isSelecting) { + selection.browse(); + return false; + } + return true; + }, + TvNavigationPopHandler.pop, + _doubleBackPopHandler.pop, + ], + child: const GestureAreaProtectorStack( child: SafeArea( top: false, bottom: false, @@ -110,27 +116,25 @@ class _CollectionPageState extends State { ), ), ), - ), - ), + ); + }, ), ); Widget page; if (device.isTelevision) { - page = TvPopScope( - child: Scaffold( - body: Row( - children: [ - TvRail( - controller: context.read(), - currentCollection: _collection, - ), - Expanded(child: body), - ], - ), - resizeToAvoidBottomInset: false, - extendBody: true, + page = Scaffold( + body: Row( + children: [ + TvRail( + controller: context.read(), + currentCollection: _collection, + ), + Expanded(child: body), + ], ), + resizeToAvoidBottomInset: false, + extendBody: true, ); } else { page = Selector( diff --git a/lib/widgets/common/behaviour/double_back_pop.dart b/lib/widgets/common/behaviour/double_back_pop.dart deleted file mode 100644 index 258e43c04..000000000 --- a/lib/widgets/common/behaviour/double_back_pop.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:async'; - -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/action_mixins/feedback.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:overlay_support/overlay_support.dart'; - -class DoubleBackPopScope extends StatefulWidget { - final Widget child; - - const DoubleBackPopScope({ - super.key, - required this.child, - }); - - @override - State createState() => _DoubleBackPopScopeState(); -} - -class _DoubleBackPopScopeState extends State with FeedbackMixin { - bool _backOnce = false; - Timer? _backTimer; - - @override - void dispose() { - _stopBackTimer(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () { - if (!Navigator.canPop(context) && settings.mustBackTwiceToExit && !_backOnce) { - _backOnce = true; - _stopBackTimer(); - _backTimer = Timer(Durations.doubleBackTimerDelay, () => _backOnce = false); - toast( - context.l10n.doubleBackExitMessage, - duration: Durations.doubleBackTimerDelay, - ); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: widget.child, - ); - } - - void _stopBackTimer() { - _backTimer?.cancel(); - } -} diff --git a/lib/widgets/common/behaviour/pop/double_back.dart b/lib/widgets/common/behaviour/pop/double_back.dart new file mode 100644 index 000000000..2956eefb0 --- /dev/null +++ b/lib/widgets/common/behaviour/pop/double_back.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:overlay_support/overlay_support.dart'; + +class DoubleBackPopHandler { + bool _backOnce = false; + Timer? _backTimer; + + void dispose() { + _stopBackTimer(); + } + + bool pop(BuildContext context) { + if (!Navigator.canPop(context) && settings.mustBackTwiceToExit && !_backOnce) { + _backOnce = true; + _stopBackTimer(); + _backTimer = Timer(Durations.doubleBackTimerDelay, () => _backOnce = false); + toast( + context.l10n.doubleBackExitMessage, + duration: Durations.doubleBackTimerDelay, + ); + return false; + } + return true; + } + + void _stopBackTimer() { + _backTimer?.cancel(); + } +} diff --git a/lib/widgets/common/behaviour/pop/scope.dart b/lib/widgets/common/behaviour/pop/scope.dart new file mode 100644 index 000000000..c099d79c6 --- /dev/null +++ b/lib/widgets/common/behaviour/pop/scope.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +// as of Flutter v3.3.10, the resolution order of multiple `WillPopScope` is random +// so this widget combines multiple handlers with a guaranteed order +class AvesPopScope extends StatelessWidget { + final List handlers; + final Widget child; + + const AvesPopScope({ + super.key, + required this.handlers, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () => SynchronousFuture(handlers.fold(true, (prev, v) => prev ? v(context) : false)), + child: child, + ); + } +} diff --git a/lib/widgets/common/behaviour/tv_pop.dart b/lib/widgets/common/behaviour/pop/tv_navigation.dart similarity index 71% rename from lib/widgets/common/behaviour/tv_pop.dart rename to lib/widgets/common/behaviour/pop/tv_navigation.dart index b669be85e..d996416a4 100644 --- a/lib/widgets/common/behaviour/tv_pop.dart +++ b/lib/widgets/common/behaviour/pop/tv_navigation.dart @@ -7,39 +7,25 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; // address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality -class TvPopScope extends StatelessWidget { - final Widget child; +class TvNavigationPopHandler { + static bool pop(BuildContext context) { + if (!device.isTelevision || _isHome(context)) { + return true; + } - const TvPopScope({ - super.key, - required this.child, - }); - - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () { - if (!device.isTelevision || _isHome(context)) { - return SynchronousFuture(true); - } - - Navigator.pushAndRemoveUntil( - context, - _getHomeRoute(), - (route) => false, - ); - return SynchronousFuture(false); - }, - child: child, + Navigator.pushAndRemoveUntil( + context, + _getHomeRoute(), + (route) => false, ); + return false; } - bool _isHome(BuildContext context) { + static bool _isHome(BuildContext context) { final homePage = settings.homePage; final currentRoute = context.currentRouteName; @@ -53,7 +39,7 @@ class TvPopScope extends StatelessWidget { } } - Route _getHomeRoute() { + static Route _getHomeRoute() { switch (settings.homePage) { case HomePageSetting.collection: return MaterialPageRoute( diff --git a/lib/widgets/common/identity/aves_app_bar.dart b/lib/widgets/common/identity/aves_app_bar.dart index 299f8dd51..9234d781f 100644 --- a/lib/widgets/common/identity/aves_app_bar.dart +++ b/lib/widgets/common/identity/aves_app_bar.dart @@ -42,7 +42,6 @@ class AvesAppBar extends StatelessWidget { child: AvesFloatingBar( builder: (context, backgroundColor, child) => Material( color: backgroundColor, - textStyle: Theme.of(context).appBarTheme.titleTextStyle, child: child, ), child: Column( @@ -63,18 +62,21 @@ class AvesAppBar extends StatelessWidget { ) : const SizedBox(width: 16), Expanded( - child: Hero( - tag: titleHeroTag, - flightShuttleBuilder: _flightShuttleBuilder, - transitionOnUserGestures: true, - child: AnimatedSwitcher( - duration: context.read().iconAnimation, - child: Row( - key: ValueKey(transitionKey), - children: [ - Expanded(child: title), - ...actions, - ], + child: DefaultTextStyle( + style: Theme.of(context).appBarTheme.titleTextStyle!, + child: Hero( + tag: titleHeroTag, + flightShuttleBuilder: _flightShuttleBuilder, + transitionOnUserGestures: true, + child: AnimatedSwitcher( + duration: context.read().iconAnimation, + child: Row( + key: ValueKey(transitionKey), + children: [ + Expanded(child: title), + ...actions, + ], + ), ), ), ), diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 20dc4c134..78a7122ca 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -4,7 +4,6 @@ import 'dart:math'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/covers.dart'; -import 'package:aves/model/device.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -60,10 +59,9 @@ class AvesFilterChip extends StatefulWidget { static const double minChipHeight = kMinInteractiveDimension; static const double minChipWidth = 80; static const double iconSize = 18; + static const double fontSize = 14; static const double decoratedContentVerticalPadding = 5; - static double get fontSize => device.isTelevision ? 18 : 14; - const AvesFilterChip({ super.key, required this.filter, diff --git a/lib/widgets/common/search/page.dart b/lib/widgets/common/search/page.dart index 76e7a7501..4e31065fb 100644 --- a/lib/widgets/common/search/page.dart +++ b/lib/widgets/common/search/page.dart @@ -2,8 +2,9 @@ import 'dart:ui'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; -import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/common/search/route.dart'; @@ -29,6 +30,7 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); final FocusNode _focusNode = FocusNode(); + final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void initState() { @@ -52,6 +54,7 @@ class _SearchPageState extends State { _unregisterWidget(widget); widget.animation.removeStatusListener(_onAnimationStatusChanged); _focusNode.dispose(); + _doubleBackPopHandler.dispose(); super.dispose(); } @@ -147,12 +150,14 @@ class _SearchPageState extends State { ), actions: widget.delegate.buildActions(context), ), - body: TvPopScope( - child: DoubleBackPopScope( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: body, - ), + body: AvesPopScope( + handlers: [ + TvNavigationPopHandler.pop, + _doubleBackPopHandler.pop, + ], + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: body, ), ), ); diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 92eb20caf..631cd8bd4 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -9,7 +9,8 @@ import 'package:aves/services/analysis_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_codecs.dart'; @@ -41,35 +42,36 @@ class _AppDebugPageState extends State { @override Widget build(BuildContext context) { - return TvPopScope( - child: Directionality( - textDirection: TextDirection.ltr, - child: Scaffold( - appBar: AppBar( - title: const Text('Debug'), - actions: [ - MenuIconTheme( - child: PopupMenuButton( - // key is expected by test driver - key: const Key('appbar-menu-button'), - itemBuilder: (context) => AppDebugAction.values - .map((v) => PopupMenuItem( - // key is expected by test driver - key: Key('menu-${v.name}'), - value: v, - child: MenuRow(text: v.name), - )) - .toList(), - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - unawaited(_onActionSelected(action)); - }, - ), + return Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + appBar: AppBar( + title: const Text('Debug'), + actions: [ + MenuIconTheme( + child: PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) => AppDebugAction.values + .map((v) => PopupMenuItem( + // key is expected by test driver + key: Key('menu-${v.name}'), + value: v, + child: MenuRow(text: v.name), + )) + .toList(), + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + unawaited(_onActionSelected(action)); + }, ), - ], - ), - body: SafeArea( + ), + ], + ), + body: AvesPopScope( + handlers: const [TvNavigationPopHandler.pop], + child: SafeArea( child: ListView( padding: const EdgeInsets.all(8), children: [ diff --git a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart index ffe8283ef..96d961f02 100644 --- a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart +++ b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart @@ -306,7 +306,7 @@ class _TagCount extends StatelessWidget { ), child: Text( '$count', - style: TextStyle(fontSize: AvesFilterChip.fontSize), + style: const TextStyle(fontSize: AvesFilterChip.fontSize), ), ); } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index dc4f2dce3..9c87691ec 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -13,8 +13,9 @@ import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; @@ -40,7 +41,6 @@ import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -80,48 +80,34 @@ class FilterGridPage extends StatelessWidget { Widget build(BuildContext context) { final body = QueryProvider( initialQuery: null, - child: WillPopScope( - onWillPop: () { - final selection = context.read>>(); - if (selection.isSelecting) { - selection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: TvPopScope( - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - top: false, - bottom: false, - child: Selector( - selector: (context, mq) => mq.padding.top, - builder: (context, mqPaddingTop, child) { - return ValueListenableBuilder( - valueListenable: appBarHeightNotifier, - builder: (context, appBarHeight, child) { - return FilterGrid( - // key is expected by test driver - key: const Key('filter-grid'), - settingsRouteKey: settingsRouteKey, - appBar: appBar, - appBarHeight: mqPaddingTop + appBarHeight, - sections: sections, - newFilters: newFilters, - sortFactor: sortFactor, - showHeaders: showHeaders, - selectable: selectable, - applyQuery: applyQuery, - emptyBuilder: emptyBuilder, - heroType: heroType, - ); - }, - ); - }, - ), - ), - ), + child: GestureAreaProtectorStack( + child: SafeArea( + top: false, + bottom: false, + child: Selector( + selector: (context, mq) => mq.padding.top, + builder: (context, mqPaddingTop, child) { + return ValueListenableBuilder( + valueListenable: appBarHeightNotifier, + builder: (context, appBarHeight, child) { + return _FilterGrid( + // key is expected by test driver + key: const Key('filter-grid'), + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: mqPaddingTop + appBarHeight, + sections: sections, + newFilters: newFilters, + sortFactor: sortFactor, + showHeaders: showHeaders, + selectable: selectable, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + heroType: heroType, + ); + }, + ); + }, ), ), ), @@ -170,7 +156,7 @@ class FilterGridPage extends StatelessWidget { } } -class FilterGrid extends StatefulWidget { +class _FilterGrid extends StatefulWidget { final String? settingsRouteKey; final Widget appBar; final double appBarHeight; @@ -182,7 +168,7 @@ class FilterGrid extends StatefulWidget { final Widget Function() emptyBuilder; final HeroType heroType; - const FilterGrid({ + const _FilterGrid({ super.key, required this.settingsRouteKey, required this.appBar, @@ -198,15 +184,17 @@ class FilterGrid extends StatefulWidget { }); @override - State> createState() => _FilterGridState(); + State<_FilterGrid> createState() => _FilterGridState(); } -class _FilterGridState extends State> { +class _FilterGridState extends State<_FilterGrid> { TileExtentController? _tileExtentController; + final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void dispose() { _tileExtentController?.dispose(); + _doubleBackPopHandler.dispose(); super.dispose(); } @@ -220,19 +208,33 @@ class _FilterGridState extends State> spacing: 8, horizontalPadding: 2, ); - return TileExtentControllerProvider( - controller: _tileExtentController!, - child: _FilterGridContent( - appBar: widget.appBar, - appBarHeight: widget.appBarHeight, - sections: widget.sections, - newFilters: widget.newFilters, - sortFactor: widget.sortFactor, - showHeaders: widget.showHeaders, - selectable: widget.selectable, - applyQuery: widget.applyQuery, - emptyBuilder: widget.emptyBuilder, - heroType: widget.heroType, + return AvesPopScope( + handlers: [ + (context) { + final selection = context.read>>(); + if (selection.isSelecting) { + selection.browse(); + return false; + } + return true; + }, + TvNavigationPopHandler.pop, + _doubleBackPopHandler.pop, + ], + child: TileExtentControllerProvider( + controller: _tileExtentController!, + child: _FilterGridContent( + appBar: widget.appBar, + appBarHeight: widget.appBarHeight, + sections: widget.sections, + newFilters: widget.newFilters, + sortFactor: widget.sortFactor, + showHeaders: widget.showHeaders, + selectable: widget.selectable, + applyQuery: widget.applyQuery, + emptyBuilder: widget.emptyBuilder, + heroType: widget.heroType, + ), ), ); } diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart index 6fae76a28..b7eb63e2e 100644 --- a/lib/widgets/navigation/tv_rail.dart +++ b/lib/widgets/navigation/tv_rail.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -11,6 +12,7 @@ import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart'; @@ -55,16 +57,7 @@ class _TvRailState extends State { _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(); - if (focusedIndex < nodes.length) { - nodes[focusedIndex].requestFocus(); - } - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) => _initFocus()); } @override @@ -85,7 +78,7 @@ class _TvRailState extends State { context.l10n.appName, style: const TextStyle( color: Colors.white, - fontSize: 44, + fontSize: 32, fontWeight: FontWeight.w300, letterSpacing: 1.0, fontFeatures: [FontFeature.enable('smcp')], @@ -94,16 +87,7 @@ class _TvRailState extends State { ], ); - final navEntries = <_NavEntry>[ - ..._buildTypeLinks(), - ..._buildAlbumLinks(context), - ..._buildPageLinks(context), - ...[ - SettingsPage.routeName, - AboutPage.routeName, - if (!kReleaseMode) AppDebugPage.routeName, - ].map(_routeNavEntry), - ]; + final navEntries = _getNavEntries(context); final rail = Focus( focusNode: _focusNode, @@ -147,6 +131,35 @@ class _TvRailState extends State { ); } + void _initFocus() { + var index = controller.focusedIndex ?? -1; + controller.focusedIndex = null; + + if (index == -1) { + final navEntries = _getNavEntries(context); + index = navEntries.indexWhere((v) => v.isHome); + } + + final nodes = _focusNode.children.toList(); + if (0 <= index && index < nodes.length) { + nodes[index].requestFocus(); + } + } + + List<_NavEntry> _getNavEntries(BuildContext context) { + final navEntries = <_NavEntry>[ + ..._buildTypeLinks(), + ..._buildAlbumLinks(context), + ..._buildPageLinks(context), + ...[ + SettingsPage.routeName, + AboutPage.routeName, + if (!kReleaseMode) AppDebugPage.routeName, + ].map(_routeNavEntry), + ]; + return navEntries; + } + List<_NavEntry> _buildTypeLinks() { final hiddenFilters = settings.hiddenFilters; final typeBookmarks = settings.drawerTypeBookmarks; @@ -160,6 +173,7 @@ class _TvRailState extends State { return _NavEntry( icon: DrawerFilterIcon(filter: filter), label: DrawerFilterTitle(filter: filter), + isHome: settings.homePage == HomePageSetting.collection && filter == null, isSelected: isSelected(), onSelection: () => _goToCollection(context, filter), ); @@ -181,6 +195,7 @@ class _TvRailState extends State { return _NavEntry( icon: DrawerFilterIcon(filter: filter), label: DrawerFilterTitle(filter: filter), + isHome: false, isSelected: isSelected(), onSelection: () => _goToCollection(context, filter), ); @@ -195,6 +210,7 @@ class _TvRailState extends State { _NavEntry _routeNavEntry(String routeName) => _NavEntry( icon: DrawerPageIcon(route: routeName), label: DrawerPageTitle(route: routeName), + isHome: settings.homePage == HomePageSetting.albums && routeName == AlbumListPage.routeName, isSelected: context.currentRouteName == routeName, onSelection: () => _goTo(routeName), ); @@ -226,14 +242,14 @@ class _TvRailState extends State { @immutable class _NavEntry { - final Widget icon; - final Widget label; - final bool isSelected; + final Widget icon, label; + final bool isHome, isSelected; final VoidCallback onSelection; const _NavEntry({ required this.icon, required this.label, + required this.isHome, required this.isSelected, required this.onSelection, }); diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 84f4059d1..cf8a6c445 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -13,7 +13,8 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/behaviour/tv_pop.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/search/route.dart'; @@ -74,7 +75,8 @@ class _SettingsPageState extends State with FeedbackMixin { if (device.isTelevision) { return Scaffold( - body: TvPopScope( + body: AvesPopScope( + handlers: const [TvNavigationPopHandler.pop], child: Row( children: [ TvRail( diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 7a15adf5c..206d3750b 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -216,7 +216,7 @@ class _ViewerVerticalPageViewState extends State { valueListenable: _isImageFocusedNotifier, builder: (context, isImageFocused, child) { return AnimatedScale( - scale: isImageFocused ? 1 : .7, + scale: isImageFocused ? 1 : .6, curve: Curves.fastOutSlowIn, duration: context.select((v) => v.tvImageFocusAnimation), child: child!,