This commit is contained in:
Thibault Deckers 2022-12-19 16:32:40 +01:00
parent 35835cdbd8
commit fbc10cc6a8
18 changed files with 292 additions and 269 deletions

View file

@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file.
- Map: edit cluster location - Map: edit cluster location
- Accessibility: optional alternative to pinch-to-zoom thumbnails - Accessibility: optional alternative to pinch-to-zoom thumbnails
- Lithuanian translation (thanks Gediminas Murauskas) - 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) - Chinese (Traditional) translation (thanks pemibe)
- Ukrainian translation (thanks Olexandr Mazur) - Ukrainian translation (thanks Olexandr Mazur)

View file

@ -9,7 +9,6 @@ import 'package:aves/model/device.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/mime.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/defaults.dart';
import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/enums/map_style.dart';
@ -250,7 +249,6 @@ class Settings extends ChangeNotifier {
null, null,
MimeFilter.video, MimeFilter.video,
FavouriteFilter.instance, FavouriteFilter.instance,
RecentlyAddedFilter.instance,
]; ];
drawerPageBookmarks = [ drawerPageBookmarks = [
AlbumListPage.routeName, AlbumListPage.routeName,

View file

@ -5,7 +5,8 @@ import 'package:aves/widgets/about/credits.dart';
import 'package:aves/widgets/about/licenses.dart'; import 'package:aves/widgets/about/licenses.dart';
import 'package:aves/widgets/about/translators.dart'; import 'package:aves/widgets/about/translators.dart';
import 'package:aves/widgets/common/basic/insets.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/common/extensions/build_context.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -47,7 +48,8 @@ class AboutPage extends StatelessWidget {
if (device.isTelevision) { if (device.isTelevision) {
return Scaffold( return Scaffold(
body: TvPopScope( body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop],
child: Row( child: Row(
children: [ children: [
TvRail( TvRail(

View file

@ -140,7 +140,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
// - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28
// - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.0.0) // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.0.0)
final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(const FadeUpwardsPageTransitionsBuilder()); final ValueNotifier<PageTransitionsBuilder> _pageTransitionsBuilderNotifier = ValueNotifier(const FadeUpwardsPageTransitionsBuilder());
final ValueNotifier<NavigationMode> _navigationModeNotifier = ValueNotifier(NavigationMode.traditional); final ValueNotifier<TvMediaQueryModifier?> _tvMediaQueryModifierNotifier = ValueNotifier(null);
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.main); final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.main);
// observers are not registered when using the same list object with different items // observers are not registered when using the same list object with different items
@ -170,7 +170,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_pageTransitionsBuilderNotifier.dispose(); _pageTransitionsBuilderNotifier.dispose();
_navigationModeNotifier.dispose(); _tvMediaQueryModifierNotifier.dispose();
_appModeNotifier.dispose(); _appModeNotifier.dispose();
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
@ -294,14 +294,14 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
// Flutter v3.4 already checks the system `Configuration.fontWeightAdjustment` to update `MediaQuery` // 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 // but we need to also check the non-standard Samsung field `bf` representing the bold font toggle
final shouldUseBoldFont = snapshot.data ?? false; final shouldUseBoldFont = snapshot.data ?? false;
return ValueListenableBuilder<NavigationMode>( final mq = MediaQuery.of(context).copyWith(
valueListenable: _navigationModeNotifier, boldText: shouldUseBoldFont,
builder: (context, navigationMode, child) { );
return ValueListenableBuilder<TvMediaQueryModifier?>(
valueListenable: _tvMediaQueryModifierNotifier,
builder: (context, modifier, child) {
return MediaQuery( return MediaQuery(
data: MediaQuery.of(context).copyWith( data: modifier?.call(mq) ?? mq,
boldText: shouldUseBoldFont,
navigationMode: navigationMode,
),
child: AvesColorsProvider( child: AvesColorsProvider(
child: ValueListenableBuilder<PageTransitionsBuilder>( child: ValueListenableBuilder<PageTransitionsBuilder>(
valueListenable: _pageTransitionsBuilderNotifier, valueListenable: _pageTransitionsBuilderNotifier,
@ -409,7 +409,10 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
await device.init(); await device.init();
if (device.isTelevision) { if (device.isTelevision) {
_pageTransitionsBuilderNotifier.value = const TvPageTransitionsBuilder(); _pageTransitionsBuilderNotifier.value = const TvPageTransitionsBuilder();
_navigationModeNotifier.value = NavigationMode.directional; _tvMediaQueryModifierNotifier.value = (mq) => mq.copyWith(
textScaleFactor: 1.1,
navigationMode: NavigationMode.directional,
);
} }
await mobileServices.init(); await mobileServices.init();
await settings.init(monitorPlatformSettings: true); await settings.init(monitorPlatformSettings: true);
@ -523,3 +526,5 @@ class StretchMaterialScrollBehavior extends MaterialScrollBehavior {
); );
} }
} }
typedef TvMediaQueryModifier = MediaQueryData Function(MediaQueryData);

View file

@ -16,8 +16,9 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.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/pop/double_back.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/build_context.dart';
import 'package:aves/widgets/common/identity/aves_fab.dart'; import 'package:aves/widgets/common/identity/aves_fab.dart';
import 'package:aves/widgets/common/providers/query_provider.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/nav_bar/nav_bar.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -52,6 +52,7 @@ class _CollectionPageState extends State<CollectionPage> {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
late CollectionLens _collection; late CollectionLens _collection;
final StreamController<DraggableScrollBarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast(); final StreamController<DraggableScrollBarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void initState() { void initState() {
@ -76,6 +77,7 @@ class _CollectionPageState extends State<CollectionPage> {
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
_collection.dispose(); _collection.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@ -89,17 +91,21 @@ class _CollectionPageState extends State<CollectionPage> {
final body = QueryProvider( final body = QueryProvider(
initialQuery: liveFilter?.query, initialQuery: liveFilter?.query,
child: Builder( child: Builder(
builder: (context) => WillPopScope( builder: (context) {
onWillPop: () { return AvesPopScope(
final selection = context.read<Selection<AvesEntry>>(); handlers: [
if (selection.isSelecting) { (context) {
selection.browse(); final selection = context.read<Selection<AvesEntry>>();
return SynchronousFuture(false); if (selection.isSelecting) {
} selection.browse();
return SynchronousFuture(true); return false;
}, }
child: const DoubleBackPopScope( return true;
child: GestureAreaProtectorStack( },
TvNavigationPopHandler.pop,
_doubleBackPopHandler.pop,
],
child: const GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
top: false, top: false,
bottom: false, bottom: false,
@ -110,27 +116,25 @@ class _CollectionPageState extends State<CollectionPage> {
), ),
), ),
), ),
), );
), },
), ),
); );
Widget page; Widget page;
if (device.isTelevision) { if (device.isTelevision) {
page = TvPopScope( page = Scaffold(
child: Scaffold( body: Row(
body: Row( children: [
children: [ TvRail(
TvRail( controller: context.read<TvRailController>(),
controller: context.read<TvRailController>(), currentCollection: _collection,
currentCollection: _collection, ),
), Expanded(child: body),
Expanded(child: body), ],
],
),
resizeToAvoidBottomInset: false,
extendBody: true,
), ),
resizeToAvoidBottomInset: false,
extendBody: true,
); );
} else { } else {
page = Selector<Settings, bool>( page = Selector<Settings, bool>(

View file

@ -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<DoubleBackPopScope> createState() => _DoubleBackPopScopeState();
}
class _DoubleBackPopScopeState extends State<DoubleBackPopScope> 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();
}
}

View file

@ -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();
}
}

View file

@ -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<bool Function(BuildContext context)> 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,
);
}
}

View file

@ -7,39 +7,25 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality // address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality
class TvPopScope extends StatelessWidget { class TvNavigationPopHandler {
final Widget child; static bool pop(BuildContext context) {
if (!device.isTelevision || _isHome(context)) {
return true;
}
const TvPopScope({ Navigator.pushAndRemoveUntil(
super.key, context,
required this.child, _getHomeRoute(),
}); (route) => false,
@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,
); );
return false;
} }
bool _isHome(BuildContext context) { static bool _isHome(BuildContext context) {
final homePage = settings.homePage; final homePage = settings.homePage;
final currentRoute = context.currentRouteName; final currentRoute = context.currentRouteName;
@ -53,7 +39,7 @@ class TvPopScope extends StatelessWidget {
} }
} }
Route _getHomeRoute() { static Route _getHomeRoute() {
switch (settings.homePage) { switch (settings.homePage) {
case HomePageSetting.collection: case HomePageSetting.collection:
return MaterialPageRoute( return MaterialPageRoute(

View file

@ -42,7 +42,6 @@ class AvesAppBar extends StatelessWidget {
child: AvesFloatingBar( child: AvesFloatingBar(
builder: (context, backgroundColor, child) => Material( builder: (context, backgroundColor, child) => Material(
color: backgroundColor, color: backgroundColor,
textStyle: Theme.of(context).appBarTheme.titleTextStyle,
child: child, child: child,
), ),
child: Column( child: Column(
@ -63,18 +62,21 @@ class AvesAppBar extends StatelessWidget {
) )
: const SizedBox(width: 16), : const SizedBox(width: 16),
Expanded( Expanded(
child: Hero( child: DefaultTextStyle(
tag: titleHeroTag, style: Theme.of(context).appBarTheme.titleTextStyle!,
flightShuttleBuilder: _flightShuttleBuilder, child: Hero(
transitionOnUserGestures: true, tag: titleHeroTag,
child: AnimatedSwitcher( flightShuttleBuilder: _flightShuttleBuilder,
duration: context.read<DurationsData>().iconAnimation, transitionOnUserGestures: true,
child: Row( child: AnimatedSwitcher(
key: ValueKey(transitionKey), duration: context.read<DurationsData>().iconAnimation,
children: [ child: Row(
Expanded(child: title), key: ValueKey(transitionKey),
...actions, children: [
], Expanded(child: title),
...actions,
],
),
), ),
), ),
), ),

View file

@ -4,7 +4,6 @@ import 'dart:math';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/covers.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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
@ -60,10 +59,9 @@ class AvesFilterChip extends StatefulWidget {
static const double minChipHeight = kMinInteractiveDimension; static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80; static const double minChipWidth = 80;
static const double iconSize = 18; static const double iconSize = 18;
static const double fontSize = 14;
static const double decoratedContentVerticalPadding = 5; static const double decoratedContentVerticalPadding = 5;
static double get fontSize => device.isTelevision ? 18 : 14;
const AvesFilterChip({ const AvesFilterChip({
super.key, super.key,
required this.filter, required this.filter,

View file

@ -2,8 +2,9 @@ import 'dart:ui';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/behaviour/pop/double_back.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_app_bar.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/delegate.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
@ -29,6 +30,7 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> { class _SearchPageState extends State<SearchPage> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void initState() { void initState() {
@ -52,6 +54,7 @@ class _SearchPageState extends State<SearchPage> {
_unregisterWidget(widget); _unregisterWidget(widget);
widget.animation.removeStatusListener(_onAnimationStatusChanged); widget.animation.removeStatusListener(_onAnimationStatusChanged);
_focusNode.dispose(); _focusNode.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@ -147,12 +150,14 @@ class _SearchPageState extends State<SearchPage> {
), ),
actions: widget.delegate.buildActions(context), actions: widget.delegate.buildActions(context),
), ),
body: TvPopScope( body: AvesPopScope(
child: DoubleBackPopScope( handlers: [
child: AnimatedSwitcher( TvNavigationPopHandler.pop,
duration: const Duration(milliseconds: 300), _doubleBackPopHandler.pop,
child: body, ],
), child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
), ),
), ),
); );

View file

@ -9,7 +9,8 @@ import 'package:aves/services/analysis_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/menu.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/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_apps.dart';
import 'package:aves/widgets/debug/android_codecs.dart'; import 'package:aves/widgets/debug/android_codecs.dart';
@ -41,35 +42,36 @@ class _AppDebugPageState extends State<AppDebugPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TvPopScope( return Directionality(
child: Directionality( textDirection: TextDirection.ltr,
textDirection: TextDirection.ltr, child: Scaffold(
child: Scaffold( appBar: AppBar(
appBar: AppBar( title: const Text('Debug'),
title: const Text('Debug'), actions: [
actions: [ MenuIconTheme(
MenuIconTheme( child: PopupMenuButton<AppDebugAction>(
child: PopupMenuButton<AppDebugAction>( // key is expected by test driver
// key is expected by test driver key: const Key('appbar-menu-button'),
key: const Key('appbar-menu-button'), itemBuilder: (context) => AppDebugAction.values
itemBuilder: (context) => AppDebugAction.values .map((v) => PopupMenuItem(
.map((v) => PopupMenuItem( // key is expected by test driver
// key is expected by test driver key: Key('menu-${v.name}'),
key: Key('menu-${v.name}'), value: v,
value: v, child: MenuRow(text: v.name),
child: MenuRow(text: v.name), ))
)) .toList(),
.toList(), onSelected: (action) async {
onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action
// wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation);
await Future.delayed(Durations.popupMenuAnimation * timeDilation); unawaited(_onActionSelected(action));
unawaited(_onActionSelected(action)); },
},
),
), ),
], ),
), ],
body: SafeArea( ),
body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop],
child: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
children: [ children: [

View file

@ -306,7 +306,7 @@ class _TagCount extends StatelessWidget {
), ),
child: Text( child: Text(
'$count', '$count',
style: TextStyle(fontSize: AvesFilterChip.fontSize), style: const TextStyle(fontSize: AvesFilterChip.fontSize),
), ),
); );
} }

View file

@ -13,8 +13,9 @@ import 'package:aves/theme/colors.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.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/pop/double_back.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/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/grid/item_tracker.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/nav_bar/nav_bar.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -80,48 +80,34 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final body = QueryProvider( final body = QueryProvider(
initialQuery: null, initialQuery: null,
child: WillPopScope( child: GestureAreaProtectorStack(
onWillPop: () { child: SafeArea(
final selection = context.read<Selection<FilterGridItem<T>>>(); top: false,
if (selection.isSelecting) { bottom: false,
selection.browse(); child: Selector<MediaQueryData, double>(
return SynchronousFuture(false); selector: (context, mq) => mq.padding.top,
} builder: (context, mqPaddingTop, child) {
return SynchronousFuture(true); return ValueListenableBuilder<double>(
}, valueListenable: appBarHeightNotifier,
child: TvPopScope( builder: (context, appBarHeight, child) {
child: DoubleBackPopScope( return _FilterGrid<T>(
child: GestureAreaProtectorStack( // key is expected by test driver
child: SafeArea( key: const Key('filter-grid'),
top: false, settingsRouteKey: settingsRouteKey,
bottom: false, appBar: appBar,
child: Selector<MediaQueryData, double>( appBarHeight: mqPaddingTop + appBarHeight,
selector: (context, mq) => mq.padding.top, sections: sections,
builder: (context, mqPaddingTop, child) { newFilters: newFilters,
return ValueListenableBuilder<double>( sortFactor: sortFactor,
valueListenable: appBarHeightNotifier, showHeaders: showHeaders,
builder: (context, appBarHeight, child) { selectable: selectable,
return FilterGrid<T>( applyQuery: applyQuery,
// key is expected by test driver emptyBuilder: emptyBuilder,
key: const Key('filter-grid'), heroType: heroType,
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<T extends CollectionFilter> extends StatelessWidget {
} }
} }
class FilterGrid<T extends CollectionFilter> extends StatefulWidget { class _FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final String? settingsRouteKey; final String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; final double appBarHeight;
@ -182,7 +168,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final HeroType heroType; final HeroType heroType;
const FilterGrid({ const _FilterGrid({
super.key, super.key,
required this.settingsRouteKey, required this.settingsRouteKey,
required this.appBar, required this.appBar,
@ -198,15 +184,17 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
}); });
@override @override
State<FilterGrid<T>> createState() => _FilterGridState<T>(); State<_FilterGrid<T>> createState() => _FilterGridState<T>();
} }
class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>> { class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>> {
TileExtentController? _tileExtentController; TileExtentController? _tileExtentController;
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
@override @override
void dispose() { void dispose() {
_tileExtentController?.dispose(); _tileExtentController?.dispose();
_doubleBackPopHandler.dispose();
super.dispose(); super.dispose();
} }
@ -220,19 +208,33 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
spacing: 8, spacing: 8,
horizontalPadding: 2, horizontalPadding: 2,
); );
return TileExtentControllerProvider( return AvesPopScope(
controller: _tileExtentController!, handlers: [
child: _FilterGridContent<T>( (context) {
appBar: widget.appBar, final selection = context.read<Selection<FilterGridItem<T>>>();
appBarHeight: widget.appBarHeight, if (selection.isSelecting) {
sections: widget.sections, selection.browse();
newFilters: widget.newFilters, return false;
sortFactor: widget.sortFactor, }
showHeaders: widget.showHeaders, return true;
selectable: widget.selectable, },
applyQuery: widget.applyQuery, TvNavigationPopHandler.pop,
emptyBuilder: widget.emptyBuilder, _doubleBackPopHandler.pop,
heroType: widget.heroType, ],
child: TileExtentControllerProvider(
controller: _tileExtentController!,
child: _FilterGridContent<T>(
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,
),
), ),
); );
} }

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.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/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.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/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/debug/app_debug_page.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/app_drawer.dart';
import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart'; import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart';
import 'package:aves/widgets/navigation/drawer/tile.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart';
@ -55,16 +57,7 @@ class _TvRailState extends State<TvRail> {
_scrollController = ScrollController(initialScrollOffset: controller.offset); _scrollController = ScrollController(initialScrollOffset: controller.offset);
_scrollController.addListener(_onScrollChanged); _scrollController.addListener(_onScrollChanged);
final focusedIndex = controller.focusedIndex; WidgetsBinding.instance.addPostFrameCallback((_) => _initFocus());
if (focusedIndex != null) {
controller.focusedIndex = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
final nodes = _focusNode.children.toList();
if (focusedIndex < nodes.length) {
nodes[focusedIndex].requestFocus();
}
});
}
} }
@override @override
@ -85,7 +78,7 @@ class _TvRailState extends State<TvRail> {
context.l10n.appName, context.l10n.appName,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 44, fontSize: 32,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
letterSpacing: 1.0, letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')], fontFeatures: [FontFeature.enable('smcp')],
@ -94,16 +87,7 @@ class _TvRailState extends State<TvRail> {
], ],
); );
final navEntries = <_NavEntry>[ final navEntries = _getNavEntries(context);
..._buildTypeLinks(),
..._buildAlbumLinks(context),
..._buildPageLinks(context),
...[
SettingsPage.routeName,
AboutPage.routeName,
if (!kReleaseMode) AppDebugPage.routeName,
].map(_routeNavEntry),
];
final rail = Focus( final rail = Focus(
focusNode: _focusNode, focusNode: _focusNode,
@ -147,6 +131,35 @@ class _TvRailState extends State<TvRail> {
); );
} }
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() { List<_NavEntry> _buildTypeLinks() {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
final typeBookmarks = settings.drawerTypeBookmarks; final typeBookmarks = settings.drawerTypeBookmarks;
@ -160,6 +173,7 @@ class _TvRailState extends State<TvRail> {
return _NavEntry( return _NavEntry(
icon: DrawerFilterIcon(filter: filter), icon: DrawerFilterIcon(filter: filter),
label: DrawerFilterTitle(filter: filter), label: DrawerFilterTitle(filter: filter),
isHome: settings.homePage == HomePageSetting.collection && filter == null,
isSelected: isSelected(), isSelected: isSelected(),
onSelection: () => _goToCollection(context, filter), onSelection: () => _goToCollection(context, filter),
); );
@ -181,6 +195,7 @@ class _TvRailState extends State<TvRail> {
return _NavEntry( return _NavEntry(
icon: DrawerFilterIcon(filter: filter), icon: DrawerFilterIcon(filter: filter),
label: DrawerFilterTitle(filter: filter), label: DrawerFilterTitle(filter: filter),
isHome: false,
isSelected: isSelected(), isSelected: isSelected(),
onSelection: () => _goToCollection(context, filter), onSelection: () => _goToCollection(context, filter),
); );
@ -195,6 +210,7 @@ class _TvRailState extends State<TvRail> {
_NavEntry _routeNavEntry(String routeName) => _NavEntry( _NavEntry _routeNavEntry(String routeName) => _NavEntry(
icon: DrawerPageIcon(route: routeName), icon: DrawerPageIcon(route: routeName),
label: DrawerPageTitle(route: routeName), label: DrawerPageTitle(route: routeName),
isHome: settings.homePage == HomePageSetting.albums && routeName == AlbumListPage.routeName,
isSelected: context.currentRouteName == routeName, isSelected: context.currentRouteName == routeName,
onSelection: () => _goTo(routeName), onSelection: () => _goTo(routeName),
); );
@ -226,14 +242,14 @@ class _TvRailState extends State<TvRail> {
@immutable @immutable
class _NavEntry { class _NavEntry {
final Widget icon; final Widget icon, label;
final Widget label; final bool isHome, isSelected;
final bool isSelected;
final VoidCallback onSelection; final VoidCallback onSelection;
const _NavEntry({ const _NavEntry({
required this.icon, required this.icon,
required this.label, required this.label,
required this.isHome,
required this.isSelected, required this.isSelected,
required this.onSelection, required this.onSelection,
}); });

View file

@ -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/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/basic/insets.dart'; 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/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/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/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
@ -74,7 +75,8 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
if (device.isTelevision) { if (device.isTelevision) {
return Scaffold( return Scaffold(
body: TvPopScope( body: AvesPopScope(
handlers: const [TvNavigationPopHandler.pop],
child: Row( child: Row(
children: [ children: [
TvRail( TvRail(

View file

@ -216,7 +216,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
valueListenable: _isImageFocusedNotifier, valueListenable: _isImageFocusedNotifier,
builder: (context, isImageFocused, child) { builder: (context, isImageFocused, child) {
return AnimatedScale( return AnimatedScale(
scale: isImageFocused ? 1 : .7, scale: isImageFocused ? 1 : .6,
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation), duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation),
child: child!, child: child!,