#437 tv: back navigation
This commit is contained in:
parent
e8bb1a77f0
commit
c0fd75777e
17 changed files with 377 additions and 202 deletions
|
@ -243,6 +243,7 @@ class Settings extends ChangeNotifier {
|
|||
if (device.isTelevision) {
|
||||
themeBrightness = AvesThemeBrightness.dark;
|
||||
mustBackTwiceToExit = false;
|
||||
// address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality
|
||||
keepScreenOn = KeepScreenOn.videoPlayback;
|
||||
enableBottomNavigationBar = false;
|
||||
drawerTypeBookmarks = [
|
||||
|
|
|
@ -5,6 +5,7 @@ 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/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/navigation/tv_rail.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -46,13 +47,15 @@ class AboutPage extends StatelessWidget {
|
|||
|
||||
if (device.isTelevision) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
body: TvPopScope(
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -112,8 +112,10 @@ class AvesApp extends StatefulWidget {
|
|||
if (urlString != null) {
|
||||
final url = Uri.parse(urlString);
|
||||
if (await ul.canLaunchUrl(url)) {
|
||||
// address `TV-WB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality
|
||||
final mode = device.isTelevision ? ul.LaunchMode.inAppWebView : ul.LaunchMode.externalApplication;
|
||||
try {
|
||||
await ul.launchUrl(url, mode: device.isTelevision ? ul.LaunchMode.inAppWebView : ul.LaunchMode.externalApplication);
|
||||
await ul.launchUrl(url, mode: mode);
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to open url=$urlString with error=$error\n$stack');
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ 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/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_fab.dart';
|
||||
import 'package:aves/widgets/common/providers/query_provider.dart';
|
||||
|
@ -97,18 +98,15 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: const DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: _collection,
|
||||
child: const CollectionGrid(
|
||||
// key is expected by test driver
|
||||
key: Key('collection-grid'),
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
),
|
||||
child: CollectionGrid(
|
||||
// key is expected by test driver
|
||||
key: Key('collection-grid'),
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -117,22 +115,25 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
),
|
||||
);
|
||||
|
||||
Widget page;
|
||||
if (device.isTelevision) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
currentCollection: _collection,
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
page = TvPopScope(
|
||||
child: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
currentCollection: _collection,
|
||||
),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
extendBody: true,
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
extendBody: true,
|
||||
);
|
||||
} else {
|
||||
return Selector<Settings, bool>(
|
||||
page = Selector<Settings, bool>(
|
||||
selector: (context, s) => s.enableBottomNavigationBar,
|
||||
builder: (context, enableBottomNavigationBar, child) {
|
||||
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
|
||||
|
@ -160,6 +161,11 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
},
|
||||
);
|
||||
}
|
||||
// this provider should be above `TvRail`
|
||||
return ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: _collection,
|
||||
child: page,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
73
lib/widgets/common/behaviour/tv_pop.dart
Normal file
73
lib/widgets/common/behaviour/tv_pop.dart
Normal file
|
@ -0,0 +1,73 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/enums/home_page.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
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;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isHome(BuildContext context) {
|
||||
final homePage = settings.homePage;
|
||||
final currentRoute = context.currentRouteName;
|
||||
|
||||
if (currentRoute != homePage.routeName) return false;
|
||||
|
||||
switch (homePage) {
|
||||
case HomePageSetting.collection:
|
||||
return context.read<CollectionLens>().filters.isEmpty;
|
||||
case HomePageSetting.albums:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Route _getHomeRoute() {
|
||||
switch (settings.homePage) {
|
||||
case HomePageSetting.collection:
|
||||
return MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
source: context.read<CollectionSource>(),
|
||||
filters: null,
|
||||
),
|
||||
);
|
||||
case HomePageSetting.albums:
|
||||
return MaterialPageRoute(
|
||||
settings: const RouteSettings(name: AlbumListPage.routeName),
|
||||
builder: (context) => const AlbumListPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/settings/enums/map_style.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -35,11 +36,13 @@ class MapButtonPanel extends StatelessWidget {
|
|||
Widget? navigationButton;
|
||||
switch (context.select<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) {
|
||||
case MapNavigationButton.back:
|
||||
navigationButton = MapOverlayButton(
|
||||
icon: const BackButtonIcon(),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
);
|
||||
if (!device.isTelevision) {
|
||||
navigationButton = MapOverlayButton(
|
||||
icon: const BackButtonIcon(),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case MapNavigationButton.map:
|
||||
if (openMapPage != null) {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
|
@ -19,6 +20,10 @@ abstract class AvesSearchDelegate extends SearchDelegate {
|
|||
|
||||
@override
|
||||
Widget? buildLeading(BuildContext context) {
|
||||
if (device.isTelevision) {
|
||||
return const Icon(AIcons.search);
|
||||
}
|
||||
|
||||
// use a property instead of checking `Navigator.canPop(context)`
|
||||
// because the navigator state changes as soon as we press back
|
||||
// so the leading may mistakenly switch to the close button
|
||||
|
|
|
@ -2,6 +2,8 @@ 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/identity/aves_app_bar.dart';
|
||||
import 'package:aves/widgets/common/search/delegate.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
|
@ -145,9 +147,13 @@ class _SearchPageState extends State<SearchPage> {
|
|||
),
|
||||
actions: widget.delegate.buildActions(context),
|
||||
),
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: body,
|
||||
body: TvPopScope(
|
||||
child: DoubleBackPopScope(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ 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/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/debug/android_apps.dart';
|
||||
import 'package:aves/widgets/debug/android_codecs.dart';
|
||||
|
@ -40,48 +41,50 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
actions: [
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<AppDebugAction>(
|
||||
// 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 TvPopScope(
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
actions: [
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<AppDebugAction>(
|
||||
// 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(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
_buildGeneralTabView(),
|
||||
const DebugAndroidAppSection(),
|
||||
const DebugAndroidCodecSection(),
|
||||
const DebugAndroidDirSection(),
|
||||
const DebugCacheSection(),
|
||||
const DebugAppDatabaseSection(),
|
||||
const DebugErrorReportingSection(),
|
||||
const DebugSettingsSection(),
|
||||
const DebugStorageSection(),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(8),
|
||||
children: [
|
||||
_buildGeneralTabView(),
|
||||
const DebugAndroidAppSection(),
|
||||
const DebugAndroidCodecSection(),
|
||||
const DebugAndroidDirSection(),
|
||||
const DebugCacheSection(),
|
||||
const DebugAppDatabaseSection(),
|
||||
const DebugErrorReportingSection(),
|
||||
const DebugSettingsSection(),
|
||||
const DebugStorageSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ 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/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/grid/item_tracker.dart';
|
||||
|
@ -88,35 +89,37 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.padding.top,
|
||||
builder: (context, mqPaddingTop, child) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: appBarHeightNotifier,
|
||||
builder: (context, appBarHeight, child) {
|
||||
return FilterGrid<T>(
|
||||
// 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: TvPopScope(
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.padding.top,
|
||||
builder: (context, mqPaddingTop, child) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: appBarHeightNotifier,
|
||||
builder: (context, appBarHeight, child) {
|
||||
return FilterGrid<T>(
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/widgets/about/about_page.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -66,10 +67,12 @@ class PageNavTile extends StatelessWidget {
|
|||
static Route routeBuilder(BuildContext context, String routeName) {
|
||||
switch (routeName) {
|
||||
case SearchPage.routeName:
|
||||
final currentCollection = context.read<CollectionLens?>();
|
||||
return SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
searchFieldLabel: context.l10n.searchCollectionFieldHint,
|
||||
source: context.read<CollectionSource>(),
|
||||
parentCollection: currentCollection?.copyWith(),
|
||||
),
|
||||
);
|
||||
default:
|
||||
|
|
|
@ -192,16 +192,19 @@ class _TvRailState extends State<TvRail> {
|
|||
return pageBookmarks.map(_routeNavEntry).toList();
|
||||
}
|
||||
|
||||
_NavEntry _routeNavEntry(String route) => _NavEntry(
|
||||
icon: DrawerPageIcon(route: route),
|
||||
label: DrawerPageTitle(route: route),
|
||||
isSelected: context.currentRouteName == route,
|
||||
onSelection: () => _goTo(route),
|
||||
_NavEntry _routeNavEntry(String routeName) => _NavEntry(
|
||||
icon: DrawerPageIcon(route: routeName),
|
||||
label: DrawerPageTitle(route: routeName),
|
||||
isSelected: context.currentRouteName == routeName,
|
||||
onSelection: () => _goTo(routeName),
|
||||
);
|
||||
|
||||
Future<void> _goTo(String routeName) async {
|
||||
// TODO TLAD [tv] check `topLevel` / `Navigator.pushAndRemoveUntil`
|
||||
await Navigator.push(context, PageNavTile.routeBuilder(context, routeName));
|
||||
void _goTo(String routeName) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
PageNavTile.routeBuilder(context, routeName),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
void _goToCollection(BuildContext context, CollectionFilter? filter) {
|
||||
|
|
|
@ -279,21 +279,25 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
|||
if (parentCollection != null) {
|
||||
_applyToParentCollectionPage(context, filter);
|
||||
} else {
|
||||
_jumpToCollectionPage(context, filter);
|
||||
_jumpToCollectionPage(context, {filter});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) {
|
||||
parentCollection!.addFilter(filter);
|
||||
// We delay closing the current page after applying the filter selection
|
||||
// so that hero animation target is ready in the `FilterBar`,
|
||||
// even when the target is a child of an `AnimatedList`.
|
||||
// Do not use `WidgetsBinding.instance.addPostFrameCallback`,
|
||||
// as it may not trigger if there is no subsequent build.
|
||||
Future.delayed(const Duration(milliseconds: 100), () => goBack(context));
|
||||
if (Navigator.canPop(context)) {
|
||||
// We delay closing the current page after applying the filter selection
|
||||
// so that hero animation target is ready in the `FilterBar`,
|
||||
// even when the target is a child of an `AnimatedList`.
|
||||
// Do not use `WidgetsBinding.instance.addPostFrameCallback`,
|
||||
// as it may not trigger if there is no subsequent build.
|
||||
Future.delayed(const Duration(milliseconds: 100), () => goBack(context));
|
||||
} else {
|
||||
_jumpToCollectionPage(context, parentCollection!.filters);
|
||||
}
|
||||
}
|
||||
|
||||
void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) {
|
||||
void _jumpToCollectionPage(BuildContext context, Set<CollectionFilter> filters) {
|
||||
clean();
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
|
@ -301,7 +305,7 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
|||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
source: source,
|
||||
filters: {filter},
|
||||
filters: filters,
|
||||
),
|
||||
),
|
||||
(route) => false,
|
||||
|
|
|
@ -13,6 +13,7 @@ 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/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
|
@ -73,62 +74,64 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
|
|||
|
||||
if (device.isTelevision) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: appBarTitle,
|
||||
elevation: 0,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _tvSelectedIndexNotifier,
|
||||
builder: (context, selectedIndex, child) {
|
||||
final rail = NavigationRail(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
extended: true,
|
||||
destinations: sections
|
||||
.map((section) => NavigationRailDestination(
|
||||
icon: section.icon(context),
|
||||
label: Text(section.title(context)),
|
||||
))
|
||||
.toList(),
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index,
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Row(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(child: rail),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SettingsSectionBody(
|
||||
loader: Future.value(sections[selectedIndex].tiles(context)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
body: TvPopScope(
|
||||
child: Row(
|
||||
children: [
|
||||
TvRail(
|
||||
controller: context.read<TvRailController>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: appBarTitle,
|
||||
elevation: 0,
|
||||
),
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _tvSelectedIndexNotifier,
|
||||
builder: (context, selectedIndex, child) {
|
||||
final rail = NavigationRail(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
extended: true,
|
||||
destinations: sections
|
||||
.map((section) => NavigationRailDestination(
|
||||
icon: section.icon(context),
|
||||
label: Text(section.title(context)),
|
||||
))
|
||||
.toList(),
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index,
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Row(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(child: rail),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SettingsSectionBody(
|
||||
loader: Future.value(sections[selectedIndex].tiles(context)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
|
@ -225,6 +226,7 @@ class _StatsPageState extends State<StatsPage> {
|
|||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !device.isTelevision,
|
||||
title: Text(l10n.statsPageTitle),
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
|
@ -354,6 +356,7 @@ class StatsTopPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !device.isTelevision,
|
||||
title: Text(title),
|
||||
),
|
||||
body: GestureAreaProtectorStack(
|
||||
|
|
|
@ -185,14 +185,17 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
|
||||
Widget? child;
|
||||
Map<ShortcutActivator, Intent>? shortcuts = {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const LeaveIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): isTelevision ? const TvShowMoreInfoIntent() : const ShowInfoIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const _LeaveIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): isTelevision ? const _TvShowMoreInfoIntent() : const _ShowInfoIntent(),
|
||||
const SingleActivator(LogicalKeyboardKey.mediaPause): const _PlayPauseIntent.pause(),
|
||||
const SingleActivator(LogicalKeyboardKey.mediaPlay): const _PlayPauseIntent.play(),
|
||||
const SingleActivator(LogicalKeyboardKey.mediaPlayPause): const _PlayPauseIntent.toggle(),
|
||||
};
|
||||
|
||||
if (hasCollection) {
|
||||
shortcuts.addAll(const {
|
||||
SingleActivator(LogicalKeyboardKey.arrowLeft): ShowPreviousIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.arrowRight): ShowNextIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.arrowLeft): _ShowPreviousIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.arrowRight): _ShowNextIntent(),
|
||||
});
|
||||
child = MultiEntryScroller(
|
||||
collection: collection!,
|
||||
|
@ -227,16 +230,18 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
autofocus: true,
|
||||
shortcuts: shortcuts,
|
||||
actions: {
|
||||
ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)),
|
||||
ShowNextIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)),
|
||||
LeaveIntent: CallbackAction<Intent>(onInvoke: (intent) => Navigator.pop(context)),
|
||||
ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)),
|
||||
_ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)),
|
||||
_ShowNextIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)),
|
||||
_LeaveIntent: CallbackAction<Intent>(onInvoke: (intent) => Navigator.pop(context)),
|
||||
_ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)),
|
||||
TvShowLessInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context)),
|
||||
TvShowMoreInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)),
|
||||
_TvShowMoreInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)),
|
||||
_PlayPauseIntent: CallbackAction<_PlayPauseIntent>(onInvoke: (intent) => _onPlayPauseIntent(intent, entry)),
|
||||
ActivateIntent: CallbackAction<Intent>(onInvoke: (intent) {
|
||||
if (isTelevision) {
|
||||
final _entry = entry;
|
||||
if (_entry != null && _entry.isVideo) {
|
||||
// address `TV-PC` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality
|
||||
final controller = context.read<VideoConductor>().getController(_entry);
|
||||
if (controller != null) {
|
||||
VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context);
|
||||
|
@ -330,30 +335,75 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onPlayPauseIntent(_PlayPauseIntent intent, entry) {
|
||||
// address `TV-PP` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality
|
||||
final _entry = entry;
|
||||
if (_entry != null && _entry.isVideo) {
|
||||
final controller = context.read<VideoConductor>().getController(_entry);
|
||||
if (controller != null) {
|
||||
bool toggle;
|
||||
switch (intent.type) {
|
||||
case _TvPlayPauseType.play:
|
||||
toggle = !controller.isPlaying;
|
||||
break;
|
||||
case _TvPlayPauseType.pause:
|
||||
toggle = controller.isPlaying;
|
||||
break;
|
||||
case _TvPlayPauseType.toggle:
|
||||
toggle = true;
|
||||
break;
|
||||
}
|
||||
if (toggle) {
|
||||
VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyboard shortcut intents
|
||||
|
||||
class ShowPreviousIntent extends Intent {
|
||||
const ShowPreviousIntent();
|
||||
class _ShowPreviousIntent extends Intent {
|
||||
const _ShowPreviousIntent();
|
||||
}
|
||||
|
||||
class ShowNextIntent extends Intent {
|
||||
const ShowNextIntent();
|
||||
class _ShowNextIntent extends Intent {
|
||||
const _ShowNextIntent();
|
||||
}
|
||||
|
||||
class LeaveIntent extends Intent {
|
||||
const LeaveIntent();
|
||||
class _LeaveIntent extends Intent {
|
||||
const _LeaveIntent();
|
||||
}
|
||||
|
||||
class ShowInfoIntent extends Intent {
|
||||
const ShowInfoIntent();
|
||||
class _ShowInfoIntent extends Intent {
|
||||
const _ShowInfoIntent();
|
||||
}
|
||||
|
||||
class TvShowLessInfoIntent extends Intent {
|
||||
const TvShowLessInfoIntent();
|
||||
}
|
||||
|
||||
class TvShowMoreInfoIntent extends Intent {
|
||||
const TvShowMoreInfoIntent();
|
||||
class _TvShowMoreInfoIntent extends Intent {
|
||||
const _TvShowMoreInfoIntent();
|
||||
}
|
||||
|
||||
class _PlayPauseIntent extends Intent {
|
||||
const _PlayPauseIntent({
|
||||
required this.type,
|
||||
});
|
||||
|
||||
const _PlayPauseIntent.play() : type = _TvPlayPauseType.play;
|
||||
|
||||
const _PlayPauseIntent.pause() : type = _TvPlayPauseType.pause;
|
||||
|
||||
const _PlayPauseIntent.toggle() : type = _TvPlayPauseType.toggle;
|
||||
|
||||
final _TvPlayPauseType type;
|
||||
}
|
||||
|
||||
enum _TvPlayPauseType {
|
||||
play,
|
||||
pause,
|
||||
toggle,
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
|
@ -36,13 +37,16 @@ class InfoAppBar extends StatelessWidget {
|
|||
final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
|
||||
|
||||
return SliverAppBar(
|
||||
leading: IconButton(
|
||||
// key is expected by test driver
|
||||
key: const Key('back-button'),
|
||||
icon: const Icon(AIcons.goUp),
|
||||
onPressed: onBackPressed,
|
||||
tooltip: context.l10n.viewerInfoBackToViewerTooltip,
|
||||
),
|
||||
leading: device.isTelevision
|
||||
? null
|
||||
: IconButton(
|
||||
// key is expected by test driver
|
||||
key: const Key('back-button'),
|
||||
icon: const Icon(AIcons.goUp),
|
||||
onPressed: onBackPressed,
|
||||
tooltip: context.l10n.viewerInfoBackToViewerTooltip,
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
title: SliverAppBarTitleWrapper(
|
||||
child: InteractiveAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
|
|
Loading…
Reference in a new issue