diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index b0aa49436..92f969921 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -15,37 +15,35 @@ class AboutPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.aboutPageTitle), - ), - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.only(top: 16), - sliver: SliverList( - delegate: SliverChildListDelegate( - const [ - AppReference(), - Divider(), - BugReport(), - Divider(), - AboutCredits(), - Divider(), - AboutTranslators(), - Divider(), - ], - ), + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.aboutPageTitle), + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 16), + sliver: SliverList( + delegate: SliverChildListDelegate( + const [ + AppReference(), + Divider(), + BugReport(), + Divider(), + AboutCredits(), + Divider(), + AboutTranslators(), + Divider(), + ], ), ), - const Licenses(), - const BottomPaddingSliver(), - ], - ), + ), + const Licenses(), + const BottomPaddingSliver(), + ], ), ), ), diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index f428a35d7..a014ed546 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -32,6 +32,7 @@ import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:dynamic_color/dynamic_color.dart'; @@ -249,7 +250,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { data: Theme.of(context).copyWith( pageTransitionsTheme: pageTransitionsTheme, ), - child: child!, + child: MediaQueryDataProvider(child: child!), ), ), ); diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 31f02a9d2..912d63ef3 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/query.dart'; @@ -18,11 +19,11 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_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/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; 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'; @@ -80,53 +81,67 @@ class _CollectionPageState extends State { @override Widget build(BuildContext context) { final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; - return MediaQueryDataProvider( - child: SelectionProvider( - child: Selector, bool>( - selector: (context, selection) => selection.selectedItems.isNotEmpty, - builder: (context, hasSelection, child) { + return SelectionProvider( + child: Selector, bool>( + selector: (context, selection) => selection.selectedItems.isNotEmpty, + builder: (context, hasSelection, child) { + 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: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + top: false, + bottom: false, + child: ChangeNotifierProvider.value( + value: _collection, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, + ), + ), + ), + ), + ), + ), + ), + ); + + if (device.isTelevision) { + return Scaffold( + body: Row( + children: [ + TvRail(currentCollection: _collection), + Expanded(child: body), + ], + ), + resizeToAvoidBottomInset: false, + extendBody: true, + ); + } else { return Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { final canNavigate = context.select, bool>((v) => v.value.canNavigate); final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; + return NotificationListener( onNotification: (notification) { _draggableScrollBarEventStreamController.add(notification.event); return false; }, child: Scaffold( - 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: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - top: false, - bottom: false, - child: ChangeNotifierProvider.value( - value: _collection, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), - settingsRouteKey: CollectionPage.routeName, - ), - ), - ), - ), - ), - ), - ), - ), + body: body, floatingActionButton: _buildFab(context, hasSelection), drawer: canNavigate ? AppDrawer(currentCollection: _collection) : null, bottomNavigationBar: showBottomNavigationBar @@ -141,8 +156,8 @@ class _CollectionPageState extends State { ); }, ); - }, - ), + } + }, ), ); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 6aae032b1..c853a2988 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -31,8 +31,8 @@ import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart'; -import 'package:aves/widgets/dialogs/location_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_page.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -517,8 +517,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final location = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: LocationPickDialog.routeName), - builder: (context) => LocationPickDialog( + settings: const RouteSettings(name: LocationPickPage.routeName), + builder: (context) => LocationPickPage( collection: mapCollection, initialLocation: clusterLocation, ), diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 66e7e1321..1f7efb28b 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -14,8 +14,8 @@ import 'package:aves/widgets/dialogs/entry_editors/edit_date_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_description_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_location_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart'; -import 'package:aves/widgets/dialogs/entry_editors/edit_tags_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; diff --git a/lib/widgets/common/search/page.dart b/lib/widgets/common/search/page.dart index 15ac0ccdd..6a3db64c7 100644 --- a/lib/widgets/common/search/page.dart +++ b/lib/widgets/common/search/page.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/common/search/route.dart'; import 'package:flutter/material.dart'; @@ -116,39 +115,37 @@ class _SearchPageState extends State { case null: break; } - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - leading: Hero( - tag: AvesAppBar.leadingHeroTag, - transitionOnUserGestures: true, - child: Center(child: widget.delegate.buildLeading(context)), - ), - title: Hero( - tag: AvesAppBar.titleHeroTag, - transitionOnUserGestures: true, - child: DefaultTextStyle.merge( - style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]), - child: TextField( - controller: widget.delegate.queryTextController, - focusNode: _focusNode, - decoration: InputDecoration( - border: InputBorder.none, - hintText: widget.delegate.searchFieldLabel, - hintStyle: theme.inputDecorationTheme.hintStyle, - ), - textInputAction: TextInputAction.search, - style: theme.textTheme.titleLarge, - onSubmitted: (_) => widget.delegate.showResults(context), + return Scaffold( + appBar: AppBar( + leading: Hero( + tag: AvesAppBar.leadingHeroTag, + transitionOnUserGestures: true, + child: Center(child: widget.delegate.buildLeading(context)), + ), + title: Hero( + tag: AvesAppBar.titleHeroTag, + transitionOnUserGestures: true, + child: DefaultTextStyle.merge( + style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]), + child: TextField( + controller: widget.delegate.queryTextController, + focusNode: _focusNode, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.delegate.searchFieldLabel, + hintStyle: theme.inputDecorationTheme.hintStyle, ), + textInputAction: TextInputAction.search, + style: theme.textTheme.titleLarge, + onSubmitted: (_) => widget.delegate.showResults(context), ), ), - actions: widget.delegate.buildActions(context), - ), - body: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: body, ), + actions: widget.delegate.buildActions(context), + ), + body: 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 ed0f20673..4f1e6a348 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -10,7 +10,6 @@ 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/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_codecs.dart'; import 'package:aves/widgets/debug/android_dirs.dart'; @@ -41,49 +40,47 @@ class _AppDebugPageState extends State { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - 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( - 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(), + ], ), ), ), diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index b46b2c650..7cd2a0c69 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -4,8 +4,8 @@ import 'package:aves/model/filters/query.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; import 'package:aves/widgets/dialogs/item_picker.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -114,7 +114,7 @@ class _AddShortcutDialogState extends State { final entry = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: ItemPickDialog.routeName), + settings: const RouteSettings(name: ItemPickPage.routeName), builder: (context) { final pickFilters = _collection.filters.toSet(); final liveFilters = pickFilters.whereType().where((v) => v.live).toSet(); @@ -122,7 +122,7 @@ class _AddShortcutDialogState extends State { pickFilters.remove(filter); pickFilters.add(QueryFilter(filter.query)); }); - return ItemPickDialog( + return ItemPickPage( collection: CollectionLens( source: _collection.source, filters: pickFilters, diff --git a/lib/widgets/dialogs/app_pick_dialog.dart b/lib/widgets/dialogs/app_pick_dialog.dart deleted file mode 100644 index a48215e94..000000000 --- a/lib/widgets/dialogs/app_pick_dialog.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:aves/image_providers/app_icon_image_provider.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/common/basic/query_bar.dart'; -import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; - -class AppPickDialog extends StatefulWidget { - static const routeName = '/app_pick'; - - final String? initialValue; - - const AppPickDialog({ - super.key, - required this.initialValue, - }); - - @override - State createState() => _AppPickDialogState(); -} - -class _AppPickDialogState extends State { - late String? _selectedValue; - late Future> _loader; - final ValueNotifier _queryNotifier = ValueNotifier(''); - - static const double iconSize = 32; - - @override - void initState() { - super.initState(); - _selectedValue = widget.initialValue; - _loader = androidAppService.getPackages(); - } - - @override - Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.appPickDialogTitle), - ), - body: SafeArea( - child: FutureBuilder>( - future: _loader, - builder: (context, snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final allPackages = snapshot.data; - if (allPackages == null) return const SizedBox(); - final packages = allPackages.where((package) => package.categoryLauncher).toList()..sort((a, b) => compareAsciiUpperCase(_displayName(a), _displayName(b))); - return Column( - children: [ - QueryBar(queryNotifier: _queryNotifier), - ValueListenableBuilder( - valueListenable: _queryNotifier, - builder: (context, query, child) { - final upQuery = query.toUpperCase().trim(); - final visiblePackages = packages.where((package) { - return { - package.packageName, - package.currentLabel, - package.englishLabel, - ...package.potentialDirs, - }.any((v) => v != null && v.toUpperCase().contains(upQuery)); - }).toList(); - final showNoneOption = upQuery.isEmpty; - final itemCount = visiblePackages.length + (showNoneOption ? 1 : 0); - return Expanded( - child: ListView.builder( - itemBuilder: (context, index) { - if (showNoneOption) { - if (index == 0) { - return ReselectableRadioListTile( - value: '', - groupValue: _selectedValue, - onChanged: (v) => Navigator.pop(context, v), - reselectable: true, - title: Text( - context.l10n.appPickDialogNone, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ); - } - index--; - } - - final package = visiblePackages[index]; - return ReselectableRadioListTile( - value: package.packageName, - groupValue: _selectedValue, - onChanged: (v) => Navigator.pop(context, v), - reselectable: true, - title: Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: const EdgeInsetsDirectional.only(end: 16), - child: Image( - image: AppIconImage( - packageName: package.packageName, - size: iconSize, - ), - width: iconSize, - height: iconSize, - ), - ), - ), - TextSpan( - text: _displayName(package), - ), - ], - ), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ); - }, - itemCount: itemCount, - ), - ); - }, - ), - ], - ); - }, - ), - ), - ), - ); - } - - String _displayName(Package package) => package.currentLabel ?? package.packageName; -} diff --git a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index 11d2fed61..21bb629df 100644 --- a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -15,7 +15,7 @@ import 'package:aves/widgets/common/basic/wheel.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -314,8 +314,8 @@ class _EditEntryDateDialogState extends State { final entry = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: ItemPickDialog.routeName), - builder: (context) => ItemPickDialog( + settings: const RouteSettings(name: ItemPickPage.routeName), + builder: (context) => ItemPickPage( collection: CollectionLens( source: _collection.source, ), diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index 6c176cc7a..12c80eba5 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -13,9 +13,9 @@ import 'package:aves/widgets/common/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; import 'package:aves/widgets/dialogs/item_picker.dart'; -import 'package:aves/widgets/dialogs/location_pick_dialog.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; @@ -186,8 +186,8 @@ class _EditEntryLocationDialogState extends State { final latLng = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: LocationPickDialog.routeName), - builder: (context) => LocationPickDialog( + settings: const RouteSettings(name: LocationPickPage.routeName), + builder: (context) => LocationPickPage( collection: mapCollection, initialLocation: _mapCoordinates, ), @@ -228,8 +228,8 @@ class _EditEntryLocationDialogState extends State { final entry = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: ItemPickDialog.routeName), - builder: (context) => ItemPickDialog( + settings: const RouteSettings(name: ItemPickPage.routeName), + builder: (context) => ItemPickPage( collection: CollectionLens( source: _collection.source, ), diff --git a/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart deleted file mode 100644 index 958855bcb..000000000 --- a/lib/widgets/dialogs/entry_editors/edit_tags_dialog.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/placeholder.dart'; -import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/expandable_filter_row.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class TagEditorPage extends StatefulWidget { - static const routeName = '/info/tag_editor'; - - final Map> filtersByEntry; - - const TagEditorPage({ - super.key, - required this.filtersByEntry, - }); - - @override - State createState() => _TagEditorPageState(); -} - -class _TagEditorPageState extends State { - final TextEditingController _newTagTextController = TextEditingController(); - final FocusNode _newTagTextFocusNode = FocusNode(); - final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); - late final List _topTags; - late final List _placeholders = [PlaceholderFilter.country, PlaceholderFilter.place]; - final List _userAddedFilters = []; - - static const Color untaggedColor = Colors.blueGrey; - - Map> get tagsByEntry => widget.filtersByEntry; - - @override - void initState() { - super.initState(); - _initTopTags(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final showCount = tagsByEntry.length > 1; - final Map entryCountByTag = {}; - tagsByEntry.entries.forEach((kv) { - kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); - }); - List> sortedTags = _sortCurrentTags(entryCountByTag); - - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.tagEditorPageTitle), - actions: [ - IconButton( - icon: const Icon(AIcons.reset), - onPressed: _reset, - tooltip: l10n.resetTooltip, - ), - ], - ), - body: SafeArea( - child: ValueListenableBuilder( - valueListenable: _expandedSectionNotifier, - builder: (context, expandedSection, child) { - return ValueListenableBuilder( - valueListenable: _newTagTextController, - builder: (context, value, child) { - final upQuery = value.text.trim().toUpperCase(); - bool containQuery(CollectionFilter v) => v.getLabel(context).toUpperCase().contains(upQuery); - final recentFilters = settings.recentTags.where(containQuery).toList(); - final topTagFilters = _topTags.where(containQuery).toList(); - final placeholderFilters = _placeholders.where(containQuery).toList(); - return ListView( - children: [ - Padding( - padding: const EdgeInsetsDirectional.only(start: 8, end: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _newTagTextController, - focusNode: _newTagTextFocusNode, - decoration: InputDecoration( - labelText: l10n.tagEditorPageNewTagFieldLabel, - ), - autofocus: true, - onSubmitted: (newTag) { - _addCustomTag(newTag); - _newTagTextFocusNode.requestFocus(); - }, - ), - ), - ValueListenableBuilder( - valueListenable: _newTagTextController, - builder: (context, value, child) { - return IconButton( - icon: const Icon(AIcons.add), - onPressed: value.text.isEmpty ? null : () => _addCustomTag(_newTagTextController.text), - tooltip: l10n.tagEditorPageAddTagTooltip, - ); - }, - ), - Selector( - selector: (context, s) => s.tagEditorCurrentFilterSectionExpanded, - builder: (context, isExpanded, child) { - return IconButton( - icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), - onPressed: sortedTags.isEmpty ? null : () => settings.tagEditorCurrentFilterSectionExpanded = !isExpanded, - tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, - ); - }, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: AnimatedCrossFade( - firstChild: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight), - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(AIcons.tagUntagged, color: untaggedColor), - const SizedBox(width: 8), - Text( - l10n.filterNoTagLabel, - style: const TextStyle(color: untaggedColor), - ), - ], - ), - ), - ), - secondChild: ExpandableFilterRow( - filters: sortedTags.map((kv) => kv.key).toList(), - isExpanded: context.select((v) => v.tagEditorCurrentFilterSectionExpanded), - removable: true, - showGenericIcon: false, - leadingBuilder: showCount - ? (filter) => _TagCount( - count: sortedTags.firstWhere((kv) => kv.key == filter).value, - ) - : null, - onTap: _removeTag, - onLongPress: null, - ), - crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: Durations.tagEditorTransition, - ), - ), - const Divider(height: 0), - _FilterRow( - title: l10n.statsTopTagsSectionTitle, - filters: topTagFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - _FilterRow( - title: l10n.tagEditorSectionRecent, - filters: recentFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - _FilterRow( - title: l10n.tagEditorSectionPlaceholders, - filters: placeholderFilters, - expandedNotifier: _expandedSectionNotifier, - onTap: _addTag, - ), - ], - ); - }, - ); - }, - ), - ), - ), - ); - } - - void _initTopTags() { - final Map entryCountByTag = {}; - final visibleEntries = context.read()?.visibleEntries; - visibleEntries?.forEach((entry) { - entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); - }); - List> sortedTopTags = _sortCurrentTags(entryCountByTag.map((key, value) => MapEntry(TagFilter(key), value))); - _topTags = sortedTopTags.map((kv) => kv.key).toList(); - } - - List> _sortCurrentTags(Map entryCountByTag) { - return entryCountByTag.entries.toList() - ..sort((kv1, kv2) { - final filter1 = kv1.key; - final filter2 = kv2.key; - - final recent1 = _userAddedFilters.indexOf(filter1); - final recent2 = _userAddedFilters.indexOf(filter2); - var c = recent2.compareTo(recent1); - if (c != 0) return c; - - final count1 = kv1.value; - final count2 = kv2.value; - c = count2.compareTo(count1); - if (c != 0) return c; - - return filter1.compareTo(filter2); - }); - } - - void _reset() { - _userAddedFilters.clear(); - tagsByEntry.forEach((entry, tags) { - final Set originalFilters = entry.tags.map(TagFilter.new).toSet(); - tags - ..clear() - ..addAll(originalFilters); - }); - setState(() {}); - } - - void _addCustomTag(String newTag) { - if (newTag.isNotEmpty) { - _addTag(TagFilter(newTag)); - } - } - - void _addTag(CollectionFilter filter) { - settings.recentTags = settings.recentTags - ..remove(filter) - ..insert(0, filter); - _userAddedFilters - ..remove(filter) - ..add(filter); - tagsByEntry.forEach((entry, tags) => tags.add(filter)); - _newTagTextController.clear(); - setState(() {}); - } - - void _removeTag(CollectionFilter filter) { - _userAddedFilters.remove(filter); - tagsByEntry.forEach((entry, filters) => filters.remove(filter)); - setState(() {}); - } -} - -class _FilterRow extends StatelessWidget { - final String title; - final List filters; - final ValueNotifier expandedNotifier; - final void Function(CollectionFilter filter) onTap; - - const _FilterRow({ - required this.title, - required this.filters, - required this.expandedNotifier, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return filters.isEmpty - ? const SizedBox() - : TitledExpandableFilterRow( - title: title, - filters: filters, - expandedNotifier: expandedNotifier, - showGenericIcon: false, - onTap: onTap, - onLongPress: null, - ); - } -} - -class _TagCount extends StatelessWidget { - final int count; - - const _TagCount({ - required this.count, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide( - color: DefaultTextStyle.of(context).style.color!, - )), - borderRadius: const BorderRadius.all(Radius.circular(123)), - ), - child: Text( - '$count', - style: const TextStyle(fontSize: AvesFilterChip.fontSize), - ), - ); - } -} diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart deleted file mode 100644 index 01a99a31c..000000000 --- a/lib/widgets/dialogs/entry_editors/rename_entry_set_dialog.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/entry.dart'; -import 'package:aves/model/naming_pattern.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/collection/collection_grid.dart'; -import 'package:aves/widgets/common/basic/menu.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/grid/theme.dart'; -import 'package:aves/widgets/common/identity/buttons.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/thumbnail/decorated.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -class RenameEntrySetPage extends StatefulWidget { - static const routeName = '/rename_entry_set'; - - final List entries; - - const RenameEntrySetPage({ - super.key, - required this.entries, - }); - - @override - State createState() => _RenameEntrySetPageState(); -} - -class _RenameEntrySetPageState extends State { - final TextEditingController _patternTextController = TextEditingController(); - final ValueNotifier _namingPatternNotifier = ValueNotifier(const NamingPattern([])); - - static const int previewMax = 10; - static const double thumbnailExtent = 48; - - List get entries => widget.entries; - - int get entryCount => entries.length; - - @override - void initState() { - super.initState(); - _patternTextController.text = settings.entryRenamingPattern; - _patternTextController.addListener(_onUserPatternChange); - _onUserPatternChange(); - } - - @override - void dispose() { - _patternTextController.removeListener(_onUserPatternChange); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.renameEntrySetPageTitle), - ), - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _patternTextController, - decoration: InputDecoration( - labelText: l10n.renameEntrySetPagePatternFieldLabel, - ), - autofocus: true, - ), - ), - MenuIconTheme( - child: PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: DateNamingProcessor.key, - child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)), - ), - PopupMenuItem( - value: NameNamingProcessor.key, - child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)), - ), - PopupMenuItem( - value: CounterNamingProcessor.key, - child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)), - ), - ]; - }, - onSelected: (key) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - _insertProcessor(key); - }, - tooltip: l10n.renameEntrySetPageInsertTooltip, - icon: const Icon(AIcons.add), - ), - ), - ], - ), - ), - const Divider(), - Padding( - padding: const EdgeInsets.all(16), - child: Text( - l10n.renameEntrySetPagePreviewSectionTitle, - style: Constants.knownTitleTextStyle, - ), - ), - Expanded( - child: Selector( - selector: (context, mq) => mq.textScaleFactor, - builder: (context, textScaleFactor, child) { - final effectiveThumbnailExtent = max(thumbnailExtent, thumbnailExtent * textScaleFactor); - return GridTheme( - extent: effectiveThumbnailExtent, - child: ListView.separated( - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 12), - itemBuilder: (context, index) { - final entry = entries[index]; - final sourceName = entry.filenameWithoutExtension ?? ''; - return Row( - children: [ - DecoratedThumbnail( - entry: entry, - tileExtent: effectiveThumbnailExtent, - selectable: false, - highlightable: false, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - sourceName, - style: TextStyle(color: Theme.of(context).textTheme.bodySmall!.color), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - const SizedBox(height: 4), - ValueListenableBuilder( - valueListenable: _namingPatternNotifier, - builder: (context, pattern, child) { - return Text( - pattern.apply(entry, index), - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - }, - ), - ], - ), - ), - ], - ); - }, - separatorBuilder: (context, index) => const SizedBox( - height: CollectionGrid.fixedExtentLayoutSpacing, - ), - itemCount: min(entryCount, previewMax), - ), - ); - }), - ), - const Divider(height: 0), - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: AvesOutlinedButton( - label: l10n.entryActionRename, - onPressed: () { - settings.entryRenamingPattern = _patternTextController.text; - Navigator.pop(context, _namingPatternNotifier.value); - }, - ), - ), - ), - ], - ), - ), - ), - ); - } - - void _onUserPatternChange() { - _namingPatternNotifier.value = NamingPattern.from( - userPattern: _patternTextController.text, - entryCount: entryCount, - ); - } - - void _insertProcessor(String key) { - final userPattern = _patternTextController.text; - final selection = _patternTextController.selection; - _patternTextController.value = _patternTextController.value.replaced( - TextRange( - start: NamingPattern.getInsertionOffset(userPattern, selection.start), - end: NamingPattern.getInsertionOffset(userPattern, selection.end), - ), - NamingPattern.defaultPatternFor(key), - ); - } -} diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart new file mode 100644 index 000000000..b181d625f --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart @@ -0,0 +1,217 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/naming_pattern.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/collection/collection_grid.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/thumbnail/decorated.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class RenameEntrySetPage extends StatefulWidget { + static const routeName = '/rename_entry_set'; + + final List entries; + + const RenameEntrySetPage({ + super.key, + required this.entries, + }); + + @override + State createState() => _RenameEntrySetPageState(); +} + +class _RenameEntrySetPageState extends State { + final TextEditingController _patternTextController = TextEditingController(); + final ValueNotifier _namingPatternNotifier = ValueNotifier(const NamingPattern([])); + + static const int previewMax = 10; + static const double thumbnailExtent = 48; + + List get entries => widget.entries; + + int get entryCount => entries.length; + + @override + void initState() { + super.initState(); + _patternTextController.text = settings.entryRenamingPattern; + _patternTextController.addListener(_onUserPatternChange); + _onUserPatternChange(); + } + + @override + void dispose() { + _patternTextController.removeListener(_onUserPatternChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.renameEntrySetPageTitle), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _patternTextController, + decoration: InputDecoration( + labelText: l10n.renameEntrySetPagePatternFieldLabel, + ), + autofocus: true, + ), + ), + MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: DateNamingProcessor.key, + child: MenuRow(text: l10n.viewerInfoLabelDate, icon: const Icon(AIcons.date)), + ), + PopupMenuItem( + value: NameNamingProcessor.key, + child: MenuRow(text: l10n.renameProcessorName, icon: const Icon(AIcons.name)), + ), + PopupMenuItem( + value: CounterNamingProcessor.key, + child: MenuRow(text: l10n.renameProcessorCounter, icon: const Icon(AIcons.counter)), + ), + ]; + }, + onSelected: (key) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + _insertProcessor(key); + }, + tooltip: l10n.renameEntrySetPageInsertTooltip, + icon: const Icon(AIcons.add), + ), + ), + ], + ), + ), + const Divider(), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l10n.renameEntrySetPagePreviewSectionTitle, + style: Constants.knownTitleTextStyle, + ), + ), + Expanded( + child: Selector( + selector: (context, mq) => mq.textScaleFactor, + builder: (context, textScaleFactor, child) { + final effectiveThumbnailExtent = max(thumbnailExtent, thumbnailExtent * textScaleFactor); + return GridTheme( + extent: effectiveThumbnailExtent, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 12), + itemBuilder: (context, index) { + final entry = entries[index]; + final sourceName = entry.filenameWithoutExtension ?? ''; + return Row( + children: [ + DecoratedThumbnail( + entry: entry, + tileExtent: effectiveThumbnailExtent, + selectable: false, + highlightable: false, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sourceName, + style: TextStyle(color: Theme.of(context).textTheme.bodySmall!.color), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + const SizedBox(height: 4), + ValueListenableBuilder( + valueListenable: _namingPatternNotifier, + builder: (context, pattern, child) { + return Text( + pattern.apply(entry, index), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, + ), + ], + ), + ), + ], + ); + }, + separatorBuilder: (context, index) => const SizedBox( + height: CollectionGrid.fixedExtentLayoutSpacing, + ), + itemCount: min(entryCount, previewMax), + ), + ); + }), + ), + const Divider(height: 0), + Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.entryActionRename, + onPressed: () { + settings.entryRenamingPattern = _patternTextController.text; + Navigator.pop(context, _namingPatternNotifier.value); + }, + ), + ), + ), + ], + ), + ), + ); + } + + void _onUserPatternChange() { + _namingPatternNotifier.value = NamingPattern.from( + userPattern: _patternTextController.text, + entryCount: entryCount, + ); + } + + void _insertProcessor(String key) { + final userPattern = _patternTextController.text; + final selection = _patternTextController.selection; + _patternTextController.value = _patternTextController.value.replaced( + TextRange( + start: NamingPattern.getInsertionOffset(userPattern, selection.start), + end: NamingPattern.getInsertionOffset(userPattern, selection.end), + ), + NamingPattern.defaultPatternFor(key), + ); + } +} diff --git a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart new file mode 100644 index 000000000..b29b3dae3 --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart @@ -0,0 +1,306 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/placeholder.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/expandable_filter_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TagEditorPage extends StatefulWidget { + static const routeName = '/info/tag_editor'; + + final Map> filtersByEntry; + + const TagEditorPage({ + super.key, + required this.filtersByEntry, + }); + + @override + State createState() => _TagEditorPageState(); +} + +class _TagEditorPageState extends State { + final TextEditingController _newTagTextController = TextEditingController(); + final FocusNode _newTagTextFocusNode = FocusNode(); + final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); + late final List _topTags; + late final List _placeholders = [PlaceholderFilter.country, PlaceholderFilter.place]; + final List _userAddedFilters = []; + + static const Color untaggedColor = Colors.blueGrey; + + Map> get tagsByEntry => widget.filtersByEntry; + + @override + void initState() { + super.initState(); + _initTopTags(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final showCount = tagsByEntry.length > 1; + final Map entryCountByTag = {}; + tagsByEntry.entries.forEach((kv) { + kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> sortedTags = _sortCurrentTags(entryCountByTag); + + return Scaffold( + appBar: AppBar( + title: Text(l10n.tagEditorPageTitle), + actions: [ + IconButton( + icon: const Icon(AIcons.reset), + onPressed: _reset, + tooltip: l10n.resetTooltip, + ), + ], + ), + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: _expandedSectionNotifier, + builder: (context, expandedSection, child) { + return ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + final upQuery = value.text.trim().toUpperCase(); + bool containQuery(CollectionFilter v) => v.getLabel(context).toUpperCase().contains(upQuery); + final recentFilters = settings.recentTags.where(containQuery).toList(); + final topTagFilters = _topTags.where(containQuery).toList(); + final placeholderFilters = _placeholders.where(containQuery).toList(); + return ListView( + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 8, end: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _newTagTextController, + focusNode: _newTagTextFocusNode, + decoration: InputDecoration( + labelText: l10n.tagEditorPageNewTagFieldLabel, + ), + autofocus: true, + onSubmitted: (newTag) { + _addCustomTag(newTag); + _newTagTextFocusNode.requestFocus(); + }, + ), + ), + ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + return IconButton( + icon: const Icon(AIcons.add), + onPressed: value.text.isEmpty ? null : () => _addCustomTag(_newTagTextController.text), + tooltip: l10n.tagEditorPageAddTagTooltip, + ); + }, + ), + Selector( + selector: (context, s) => s.tagEditorCurrentFilterSectionExpanded, + builder: (context, isExpanded, child) { + return IconButton( + icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), + onPressed: sortedTags.isEmpty ? null : () => settings.tagEditorCurrentFilterSectionExpanded = !isExpanded, + tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, + ); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: AnimatedCrossFade( + firstChild: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(AIcons.tagUntagged, color: untaggedColor), + const SizedBox(width: 8), + Text( + l10n.filterNoTagLabel, + style: const TextStyle(color: untaggedColor), + ), + ], + ), + ), + ), + secondChild: ExpandableFilterRow( + filters: sortedTags.map((kv) => kv.key).toList(), + isExpanded: context.select((v) => v.tagEditorCurrentFilterSectionExpanded), + removable: true, + showGenericIcon: false, + leadingBuilder: showCount + ? (filter) => _TagCount( + count: sortedTags.firstWhere((kv) => kv.key == filter).value, + ) + : null, + onTap: _removeTag, + onLongPress: null, + ), + crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: Durations.tagEditorTransition, + ), + ), + const Divider(height: 0), + _FilterRow( + title: l10n.statsTopTagsSectionTitle, + filters: topTagFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + _FilterRow( + title: l10n.tagEditorSectionRecent, + filters: recentFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + _FilterRow( + title: l10n.tagEditorSectionPlaceholders, + filters: placeholderFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + ], + ); + }, + ); + }, + ), + ), + ); + } + + void _initTopTags() { + final Map entryCountByTag = {}; + final visibleEntries = context.read()?.visibleEntries; + visibleEntries?.forEach((entry) { + entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> sortedTopTags = _sortCurrentTags(entryCountByTag.map((key, value) => MapEntry(TagFilter(key), value))); + _topTags = sortedTopTags.map((kv) => kv.key).toList(); + } + + List> _sortCurrentTags(Map entryCountByTag) { + return entryCountByTag.entries.toList() + ..sort((kv1, kv2) { + final filter1 = kv1.key; + final filter2 = kv2.key; + + final recent1 = _userAddedFilters.indexOf(filter1); + final recent2 = _userAddedFilters.indexOf(filter2); + var c = recent2.compareTo(recent1); + if (c != 0) return c; + + final count1 = kv1.value; + final count2 = kv2.value; + c = count2.compareTo(count1); + if (c != 0) return c; + + return filter1.compareTo(filter2); + }); + } + + void _reset() { + _userAddedFilters.clear(); + tagsByEntry.forEach((entry, tags) { + final Set originalFilters = entry.tags.map(TagFilter.new).toSet(); + tags + ..clear() + ..addAll(originalFilters); + }); + setState(() {}); + } + + void _addCustomTag(String newTag) { + if (newTag.isNotEmpty) { + _addTag(TagFilter(newTag)); + } + } + + void _addTag(CollectionFilter filter) { + settings.recentTags = settings.recentTags + ..remove(filter) + ..insert(0, filter); + _userAddedFilters + ..remove(filter) + ..add(filter); + tagsByEntry.forEach((entry, tags) => tags.add(filter)); + _newTagTextController.clear(); + setState(() {}); + } + + void _removeTag(CollectionFilter filter) { + _userAddedFilters.remove(filter); + tagsByEntry.forEach((entry, filters) => filters.remove(filter)); + setState(() {}); + } +} + +class _FilterRow extends StatelessWidget { + final String title; + final List filters; + final ValueNotifier expandedNotifier; + final void Function(CollectionFilter filter) onTap; + + const _FilterRow({ + required this.title, + required this.filters, + required this.expandedNotifier, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return filters.isEmpty + ? const SizedBox() + : TitledExpandableFilterRow( + title: title, + filters: filters, + expandedNotifier: expandedNotifier, + showGenericIcon: false, + onTap: onTap, + onLongPress: null, + ); + } +} + +class _TagCount extends StatelessWidget { + final int count; + + const _TagCount({ + required this.count, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: DefaultTextStyle.of(context).style.color!, + )), + borderRadius: const BorderRadius.all(Radius.circular(123)), + ), + child: Text( + '$count', + style: const TextStyle(fontSize: AvesFilterChip.fontSize), + ), + ); + } +} diff --git a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index 71164c2b2..d2bd5b693 100644 --- a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -13,10 +13,10 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/color_list_tile.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/dialogs/app_pick_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; import 'package:aves/widgets/dialogs/item_picker.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/app_pick_page.dart'; +import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; @@ -346,8 +346,8 @@ class _CoverSelectionDialogState extends State { final entry = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: ItemPickDialog.routeName), - builder: (context) => ItemPickDialog( + settings: const RouteSettings(name: ItemPickPage.routeName), + builder: (context) => ItemPickPage( collection: CollectionLens( source: context.read(), filters: {filter}, @@ -367,8 +367,8 @@ class _CoverSelectionDialogState extends State { final package = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: AppPickDialog.routeName), - builder: (context) => AppPickDialog( + settings: const RouteSettings(name: AppPickPage.routeName), + builder: (context) => AppPickPage( initialValue: _customPackage, ), fullscreenDialog: true, diff --git a/lib/widgets/dialogs/pick_dialogs/app_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/app_pick_page.dart new file mode 100644 index 000000000..9cc912c76 --- /dev/null +++ b/lib/widgets/dialogs/pick_dialogs/app_pick_page.dart @@ -0,0 +1,144 @@ +import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class AppPickPage extends StatefulWidget { + static const routeName = '/app_pick'; + + final String? initialValue; + + const AppPickPage({ + super.key, + required this.initialValue, + }); + + @override + State createState() => _AppPickPageState(); +} + +class _AppPickPageState extends State { + late String? _selectedValue; + late Future> _loader; + final ValueNotifier _queryNotifier = ValueNotifier(''); + + static const double iconSize = 32; + + @override + void initState() { + super.initState(); + _selectedValue = widget.initialValue; + _loader = androidAppService.getPackages(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.appPickDialogTitle), + ), + body: SafeArea( + child: FutureBuilder>( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final allPackages = snapshot.data; + if (allPackages == null) return const SizedBox(); + final packages = allPackages.where((package) => package.categoryLauncher).toList()..sort((a, b) => compareAsciiUpperCase(_displayName(a), _displayName(b))); + return Column( + children: [ + QueryBar(queryNotifier: _queryNotifier), + ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) { + final upQuery = query.toUpperCase().trim(); + final visiblePackages = packages.where((package) { + return { + package.packageName, + package.currentLabel, + package.englishLabel, + ...package.potentialDirs, + }.any((v) => v != null && v.toUpperCase().contains(upQuery)); + }).toList(); + final showNoneOption = upQuery.isEmpty; + final itemCount = visiblePackages.length + (showNoneOption ? 1 : 0); + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + if (showNoneOption) { + if (index == 0) { + return ReselectableRadioListTile( + value: '', + groupValue: _selectedValue, + onChanged: (v) => Navigator.pop(context, v), + reselectable: true, + title: Text( + context.l10n.appPickDialogNone, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } + index--; + } + + final package = visiblePackages[index]; + return ReselectableRadioListTile( + value: package.packageName, + groupValue: _selectedValue, + onChanged: (v) => Navigator.pop(context, v), + reselectable: true, + title: Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: Image( + image: AppIconImage( + packageName: package.packageName, + size: iconSize, + ), + width: iconSize, + height: iconSize, + ), + ), + ), + TextSpan( + text: _displayName(package), + ), + ], + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + }, + itemCount: itemCount, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ); + } + + String _displayName(Package package) => package.currentLabel ?? package.packageName; +} diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart similarity index 60% rename from lib/widgets/dialogs/item_pick_dialog.dart rename to lib/widgets/dialogs/pick_dialogs/item_pick_page.dart index 29627b8a3..7f84a01a8 100644 --- a/lib/widgets/dialogs/item_pick_dialog.dart +++ b/lib/widgets/dialogs/pick_dialogs/item_pick_page.dart @@ -5,28 +5,27 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class ItemPickDialog extends StatefulWidget { +class ItemPickPage extends StatefulWidget { static const routeName = '/item_pick'; final CollectionLens collection; - const ItemPickDialog({ + const ItemPickPage({ super.key, required this.collection, }); @override - State createState() => _ItemPickDialogState(); + State createState() => _ItemPickPageState(); } -class _ItemPickDialogState extends State { +class _ItemPickPageState extends State { CollectionLens get collection => widget.collection; @override @@ -40,20 +39,18 @@ class _ItemPickDialogState extends State { final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return ListenableProvider>.value( value: ValueNotifier(AppMode.pickMediaInternal), - child: MediaQueryDataProvider( - child: Scaffold( - body: SelectionProvider( - child: QueryProvider( - initialQuery: liveFilter?.query, - child: GestureAreaProtectorStack( - child: SafeArea( - top: false, - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - settingsRouteKey: CollectionPage.routeName, - ), + child: Scaffold( + body: SelectionProvider( + child: QueryProvider( + initialQuery: liveFilter?.query, + child: GestureAreaProtectorStack( + child: SafeArea( + top: false, + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + settingsRouteKey: CollectionPage.routeName, ), ), ), diff --git a/lib/widgets/dialogs/location_pick_dialog.dart b/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart similarity index 95% rename from lib/widgets/dialogs/location_pick_dialog.dart rename to lib/widgets/dialogs/pick_dialogs/location_pick_page.dart index a39882683..6f2a1e4d1 100644 --- a/lib/widgets/dialogs/location_pick_dialog.dart +++ b/lib/widgets/dialogs/pick_dialogs/location_pick_page.dart @@ -14,20 +14,19 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/map_theme_provider.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves_map/aves_map.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -class LocationPickDialog extends StatelessWidget { +class LocationPickPage extends StatelessWidget { static const routeName = '/location_pick'; final CollectionLens? collection; final LatLng? initialLocation; - const LocationPickDialog({ + const LocationPickPage({ super.key, required this.collection, required this.initialLocation, @@ -35,17 +34,15 @@ class LocationPickDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: SafeArea( - left: false, - top: false, - right: false, - bottom: true, - child: _Content( - collection: collection, - initialLocation: initialLocation, - ), + return Scaffold( + body: SafeArea( + left: false, + top: false, + right: false, + bottom: true, + child: _Content( + collection: collection, + initialLocation: initialLocation, ), ), ); diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 92e3183bb..f030636fd 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/query.dart'; @@ -24,7 +25,6 @@ import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; @@ -37,6 +37,7 @@ import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_layout.dart'; 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'; @@ -76,64 +77,78 @@ class FilterGridPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Selector( + final body = QueryProvider( + initialQuery: null, + child: WillPopScope( + onWillPop: () { + final selection = context.read>>(); + if (selection.isSelecting) { + selection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + 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, + ); + }, + ); + }, + ), + ), + ), + ), + ), + ); + + if (device.isTelevision) { + return Scaffold( + body: Row( + children: [ + const TvRail(), + Expanded(child: body), + ], + ), + resizeToAvoidBottomInset: false, + extendBody: true, + ); + } else { + return Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { final canNavigate = context.select, bool>((v) => v.value.canNavigate); final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; + return NotificationListener( onNotification: (notification) { _draggableScrollBarEventStreamController.add(notification.event); return false; }, child: Scaffold( - body: QueryProvider( - initialQuery: null, - child: WillPopScope( - onWillPop: () { - final selection = context.read>>(); - if (selection.isSelecting) { - selection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - 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, - ); - }, - ); - }, - ), - ), - ), - ), - ), - ), + body: body, drawer: canNavigate ? const AppDrawer() : null, bottomNavigationBar: showBottomNavigationBar ? AppBottomNavBar( @@ -145,8 +160,8 @@ class FilterGridPage extends StatelessWidget { ), ); }, - ), - ); + ); + } } } diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 5fe35a636..22f9ac9db 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -23,7 +23,6 @@ import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/map_theme_provider.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/map/map_info_row.dart'; @@ -56,18 +55,16 @@ class MapPage extends StatelessWidget { // as the map can be stacked on top of other pages // that catch highlight events and will not let it bubble up return HighlightInfoProvider( - child: MediaQueryDataProvider( - child: Scaffold( - body: SafeArea( - left: false, - top: false, - right: false, - bottom: true, - child: _Content( - collection: collection, - initialEntry: initialEntry, - overlayEntry: overlayEntry, - ), + child: Scaffold( + body: SafeArea( + left: false, + top: false, + right: false, + bottom: true, + child: _Content( + collection: collection, + initialEntry: initialEntry, + overlayEntry: overlayEntry, ), ), ), diff --git a/lib/widgets/navigation/drawer/app_drawer.dart b/lib/widgets/navigation/drawer/app_drawer.dart index e418c9540..0a3acf5e6 100644 --- a/lib/widgets/navigation/drawer/app_drawer.dart +++ b/lib/widgets/navigation/drawer/app_drawer.dart @@ -230,25 +230,21 @@ class _AppDrawerState extends State { return [ const Divider(), ...pageBookmarks.map((route) { - WidgetBuilder? pageBuilder; Widget? trailing; switch (route) { case AlbumListPage.routeName: - pageBuilder = (_) => const AlbumListPage(); trailing = StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.rawAlbums.length}'), ); break; case CountryListPage.routeName: - pageBuilder = (_) => const CountryListPage(); trailing = StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.sortedCountries.length}'), ); break; case TagListPage.routeName: - pageBuilder = (_) => const TagListPage(); trailing = StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.sortedTags.length}'), @@ -261,7 +257,6 @@ class _AppDrawerState extends State { key: Key('drawer-page-$route'), trailing: trailing, routeName: route, - pageBuilder: pageBuilder ?? (_) => const SizedBox(), ); }), ]; @@ -281,11 +276,10 @@ class _AppDrawerState extends State { ); } - Widget get debugTile => PageNavTile( + Widget get debugTile => const PageNavTile( // key is expected by test driver - key: const Key('drawer-debug'), + key: Key('drawer-debug'), topLevel: false, routeName: AppDebugPage.routeName, - pageBuilder: (_) => const AppDebugPage(), ); } diff --git a/lib/widgets/navigation/drawer/collection_nav_tile.dart b/lib/widgets/navigation/drawer/collection_nav_tile.dart index 968b7df6f..8a8787d0e 100644 --- a/lib/widgets/navigation/drawer/collection_nav_tile.dart +++ b/lib/widgets/navigation/drawer/collection_nav_tile.dart @@ -85,7 +85,7 @@ class AlbumNavTile extends StatelessWidget { @override Widget build(BuildContext context) { final source = context.read(); - var filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); + final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); return CollectionNavTile( leading: DrawerFilterIcon(filter: filter), title: DrawerFilterTitle(filter: filter), diff --git a/lib/widgets/navigation/drawer/page_nav_tile.dart b/lib/widgets/navigation/drawer/page_nav_tile.dart index e72b6d780..92fe5b76f 100644 --- a/lib/widgets/navigation/drawer/page_nav_tile.dart +++ b/lib/widgets/navigation/drawer/page_nav_tile.dart @@ -1,24 +1,27 @@ +import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/debug/app_debug_page.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/countries_page.dart'; +import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart'; +import 'package:aves/widgets/settings/settings_page.dart'; import 'package:flutter/material.dart'; class PageNavTile extends StatelessWidget { final Widget? trailing; final bool topLevel; final String routeName; - final WidgetBuilder? pageBuilder; const PageNavTile({ super.key, this.trailing, this.topLevel = true, required this.routeName, - required this.pageBuilder, }); @override Widget build(BuildContext context) { - final _pageBuilder = pageBuilder; return SafeArea( top: false, bottom: false, @@ -37,26 +40,43 @@ class PageNavTile extends StatelessWidget { ), ) : null, - onTap: _pageBuilder != null - ? () { - Navigator.pop(context); - final route = MaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: _pageBuilder, - ); - if (topLevel) { - Navigator.pushAndRemoveUntil( - context, - route, - (route) => false, - ); - } else { - Navigator.push(context, route); - } - } - : null, + onTap: () { + Navigator.pop(context); + final route = MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: pageBuilder(routeName), + ); + if (topLevel) { + Navigator.pushAndRemoveUntil( + context, + route, + (route) => false, + ); + } else { + Navigator.push(context, route); + } + }, selected: context.currentRouteName == routeName, ), ); } + + static WidgetBuilder pageBuilder(String route) { + switch (route) { + case AlbumListPage.routeName: + return (_) => const AlbumListPage(); + case CountryListPage.routeName: + return (_) => const CountryListPage(); + case TagListPage.routeName: + return (_) => const TagListPage(); + case SettingsPage.routeName: + return (_) => const SettingsPage(); + case AboutPage.routeName: + return (_) => const AboutPage(); + case AppDebugPage.routeName: + return (_) => const AppDebugPage(); + default: + throw Exception('unknown route=$route'); + } + } } diff --git a/lib/widgets/navigation/drawer/tile.dart b/lib/widgets/navigation/drawer/tile.dart index 89eb9caaf..dee17e807 100644 --- a/lib/widgets/navigation/drawer/tile.dart +++ b/lib/widgets/navigation/drawer/tile.dart @@ -2,9 +2,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; -import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:aves/widgets/filter_grids/countries_page.dart'; -import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/navigation/nav_display.dart'; import 'package:flutter/material.dart'; @@ -52,16 +49,14 @@ class DrawerPageIcon extends StatelessWidget { final icon = NavigationDisplay.getPageIcon(route); if (icon != null) { switch (route) { - case AlbumListPage.routeName: - case CountryListPage.routeName: - case TagListPage.routeName: - return Icon(icon); case AppDebugPage.routeName: return ShaderMask( shaderCallback: AvesColorsData.debugGradient.createShader, blendMode: BlendMode.srcIn, child: Icon(icon), ); + default: + return Icon(icon); } } return const SizedBox(); diff --git a/lib/widgets/navigation/nav_display.dart b/lib/widgets/navigation/nav_display.dart index 95756de10..bc4ed12d8 100644 --- a/lib/widgets/navigation/nav_display.dart +++ b/lib/widgets/navigation/nav_display.dart @@ -3,11 +3,13 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; +import 'package:aves/widgets/settings/settings_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -35,6 +37,10 @@ class NavigationDisplay { return l10n.drawerCountryPage; case TagListPage.routeName: return l10n.drawerTagPage; + case SettingsPage.routeName: + return l10n.settingsPageTitle; + case AboutPage.routeName: + return l10n.aboutPageTitle; case AppDebugPage.routeName: return 'Debug'; default: @@ -50,6 +56,10 @@ class NavigationDisplay { return AIcons.location; case TagListPage.routeName: return AIcons.tag; + case SettingsPage.routeName: + return AIcons.settings; + case AboutPage.routeName: + return AIcons.info; case AppDebugPage.routeName: return AIcons.debug; default: diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart new file mode 100644 index 000000000..1a916854c --- /dev/null +++ b/lib/widgets/navigation/tv_rail.dart @@ -0,0 +1,194 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.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/about/about_page.dart'; +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/navigation/drawer/app_drawer.dart'; +import 'package:aves/widgets/navigation/drawer/page_nav_tile.dart'; +import 'package:aves/widgets/navigation/drawer/tile.dart'; +import 'package:aves/widgets/settings/settings_page.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TvRail extends StatefulWidget { + // collection loaded in the `CollectionPage`, if any + final CollectionLens? currentCollection; + + const TvRail({ + super.key, + this.currentCollection, + }); + + @override + State createState() => _TvRailState(); +} + +class _TvRailState extends State { + final _scrollController = ScrollController(); + + CollectionLens? get currentCollection => widget.currentCollection; + + @override + Widget build(BuildContext context) { + final header = Row( + children: [ + const AvesLogo(size: 48), + const SizedBox(width: 16), + Text( + context.l10n.appName, + style: const TextStyle( + color: Colors.white, + fontSize: 44, + fontWeight: FontWeight.w300, + letterSpacing: 1.0, + fontFeatures: [FontFeature.enable('smcp')], + ), + ), + ], + ); + + final navEntries = <_NavEntry>[ + ..._buildTypeLinks(), + ..._buildAlbumLinks(context), + ..._buildPageLinks(context), + ...[ + SettingsPage.routeName, + AboutPage.routeName, + ].map(_routeNavEntry), + if (!kReleaseMode) _routeNavEntry(AppDebugPage.routeName), + ]; + + final rail = NavigationRail( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + extended: true, + destinations: navEntries + .map((v) => NavigationRailDestination( + icon: v.icon, + label: v.label, + )) + .toList(), + selectedIndex: max(0, navEntries.indexWhere(((v) => v.isSelected))), + onDestinationSelected: (index) => navEntries[index].onSelection(), + ); + + return Column( + children: [ + const SizedBox(height: 8), + header, + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + controller: _scrollController, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight(child: rail), + ), + ); + }, + ), + ), + ], + ); + } + + List<_NavEntry> _buildTypeLinks() { + final hiddenFilters = settings.hiddenFilters; + final typeBookmarks = settings.drawerTypeBookmarks; + final currentFilters = currentCollection?.filters; + return typeBookmarks.where((filter) => !hiddenFilters.contains(filter)).map((filter) { + bool isSelected() { + if (currentFilters == null || currentFilters.length > 1) return false; + return currentFilters.firstOrNull == filter; + } + + return _NavEntry( + icon: DrawerFilterIcon(filter: filter), + label: DrawerFilterTitle(filter: filter), + isSelected: isSelected(), + onSelection: () => _goToCollection(context, filter), + ); + }).toList(); + } + + List<_NavEntry> _buildAlbumLinks(BuildContext context) { + final source = context.read(); + final currentFilters = currentCollection?.filters; + final albums = settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context); + return albums.map((album) { + final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); + bool isSelected() { + if (currentFilters == null || currentFilters.length > 1) return false; + final currentFilter = currentFilters.firstOrNull; + return currentFilter is AlbumFilter && currentFilter.album == album; + } + + return _NavEntry( + icon: DrawerFilterIcon(filter: filter), + label: DrawerFilterTitle(filter: filter), + isSelected: isSelected(), + onSelection: () => _goToCollection(context, filter), + ); + }).toList(); + } + + List<_NavEntry> _buildPageLinks(BuildContext context) { + final pageBookmarks = settings.drawerPageBookmarks; + 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), + ); + + Future _goTo(String routeName) async { + await Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: PageNavTile.pageBuilder(routeName), + )); + } + + void _goToCollection(BuildContext context, CollectionFilter? filter) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: context.read(), + filters: {filter}, + ), + ), + (route) => false, + ); + } +} + +@immutable +class _NavEntry { + final Widget icon; + final Widget label; + final bool isSelected; + final VoidCallback onSelection; + + const _NavEntry({ + required this.icon, + required this.label, + required this.isSelected, + required this.onSelection, + }); +} diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index 58645446e..62e0d9ed6 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -5,7 +5,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/common/quick_actions/action_button.dart'; import 'package:aves/widgets/settings/common/quick_actions/action_panel.dart'; import 'package:aves/widgets/settings/common/quick_actions/available_actions.dart'; @@ -38,20 +37,18 @@ class QuickActionEditorPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: SafeArea( - child: QuickActionEditorBody( - bannerText: bannerText, - allAvailableActions: allAvailableActions, - actionIcon: actionIcon, - actionText: actionText, - load: load, - save: save, - ), + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: SafeArea( + child: QuickActionEditorBody( + bannerText: bannerText, + allAvailableActions: allAvailableActions, + actionIcon: actionIcon, + actionText: actionText, + load: load, + save: save, ), ), ); diff --git a/lib/widgets/settings/home_widget_settings_page.dart b/lib/widgets/settings/home_widget_settings_page.dart index 8dc221502..2ab6b59aa 100644 --- a/lib/widgets/settings/home_widget_settings_page.dart +++ b/lib/widgets/settings/home_widget_settings_page.dart @@ -11,7 +11,6 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_widget.dart'; import 'package:aves/widgets/settings/common/collection_tile.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; @@ -68,59 +67,57 @@ class _HomeWidgetSettingsPageState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.settingsWidgetPageTitle), - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: ListView( - children: [ - _buildShapeSelector(), - ListTile( - title: Text(l10n.settingsWidgetShowOutline), - trailing: HomeWidgetOutlineSelector( - getter: () => _outline, - setter: (v) => setState(() => _outline = v), - ), + return Scaffold( + appBar: AppBar( + title: Text(l10n.settingsWidgetPageTitle), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + _buildShapeSelector(), + ListTile( + title: Text(l10n.settingsWidgetShowOutline), + trailing: HomeWidgetOutlineSelector( + getter: () => _outline, + setter: (v) => setState(() => _outline = v), ), - SettingsSelectionListTile( - values: WidgetOpenPage.values, - getName: (context, v) => v.getName(context), - selector: (context, s) => _openPage, - onSelection: (v) => setState(() => _openPage = v), - tileTitle: l10n.settingsWidgetOpenPage, - ), - SettingsSelectionListTile( - values: WidgetDisplayedItem.values, - getName: (context, v) => v.getName(context), - selector: (context, s) => _displayedItem, - onSelection: (v) => setState(() => _displayedItem = v), - tileTitle: l10n.settingsWidgetDisplayedItem, - ), - SettingsCollectionTile( - filters: _collectionFilters, - onSelection: (v) => setState(() => _collectionFilters = v), - ), - ], - ), + ), + SettingsSelectionListTile( + values: WidgetOpenPage.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => _openPage, + onSelection: (v) => setState(() => _openPage = v), + tileTitle: l10n.settingsWidgetOpenPage, + ), + SettingsSelectionListTile( + values: WidgetDisplayedItem.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => _displayedItem, + onSelection: (v) => setState(() => _displayedItem = v), + tileTitle: l10n.settingsWidgetDisplayedItem, + ), + SettingsCollectionTile( + filters: _collectionFilters, + onSelection: (v) => setState(() => _collectionFilters = v), + ), + ], ), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.all(8), - child: AvesOutlinedButton( - label: l10n.saveTooltip, - onPressed: () { - _saveSettings(); - WidgetService.configure(); - }, - ), + ), + const Divider(height: 0), + Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.saveTooltip, + onPressed: () { + _saveSettings(); + WidgetService.configure(); + }, ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/settings/language/language.dart b/lib/widgets/settings/language/language.dart index a40b735f2..ff33641f5 100644 --- a/lib/widgets/settings/language/language.dart +++ b/lib/widgets/settings/language/language.dart @@ -10,7 +10,7 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; -import 'package:aves/widgets/settings/language/locale.dart'; +import 'package:aves/widgets/settings/language/locale_tile.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart deleted file mode 100644 index 1c59db036..000000000 --- a/lib/widgets/settings/language/locale.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:collection'; - -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/aves_app.dart'; -import 'package:aves/widgets/common/basic/query_bar.dart'; -import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/settings/language/locales.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -class LocaleTile extends StatelessWidget { - static const systemLocaleOption = Locale('system'); - - const LocaleTile({super.key}); - - @override - Widget build(BuildContext context) { - return ListTile( - // key is expected by test driver - key: const Key('tile-language'), - title: Text(context.l10n.settingsLanguageTile), - subtitle: Selector( - selector: (context, s) => settings.locale, - builder: (context, locale, child) { - return Text(locale == null ? context.l10n.settingsSystemDefault : getLocaleName(locale)); - }, - ), - onTap: () async { - final value = await Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: LocaleSelectionPage.routeName), - builder: (context) => const LocaleSelectionPage(), - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.pageTransitionAnimation * timeDilation); - if (value != null) { - settings.locale = value == systemLocaleOption ? null : value; - } - }, - ); - } - - static String getLocaleName(Locale locale) { - // the package `flutter_localized_locales` has the answer for all locales - // but it comes with 3 MB of assets - return SupportedLocales.languagesByLanguageCode[locale.languageCode] ?? locale.toString(); - } -} - -class LocaleSelectionPage extends StatefulWidget { - static const routeName = '/settings/locale'; - - const LocaleSelectionPage({super.key}); - - @override - State createState() => _LocaleSelectionPageState(); -} - -class _LocaleSelectionPageState extends State { - late Locale _selectedValue; - final ValueNotifier _queryNotifier = ValueNotifier(''); - - @override - void initState() { - super.initState(); - _selectedValue = settings.locale ?? LocaleTile.systemLocaleOption; - } - - @override - Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsLanguagePageTitle), - ), - body: SafeArea( - child: ValueListenableBuilder( - valueListenable: _queryNotifier, - builder: (context, query, child) { - final upQuery = query.toUpperCase().trim(); - return ListView( - children: [ - QueryBar( - queryNotifier: _queryNotifier, - leadingPadding: const EdgeInsetsDirectional.only(start: 24, end: 8), - ), - ..._getLocaleOptions(context).entries.where((kv) { - if (upQuery.isEmpty) return true; - final title = kv.value; - return title.toUpperCase().contains(upQuery); - }).map((kv) { - final value = kv.key; - final title = kv.value; - return ReselectableRadioListTile( - // key is expected by test driver - key: Key(value.toString()), - value: value, - groupValue: _selectedValue, - onChanged: (v) => Navigator.pop(context, v), - reselectable: true, - title: Text( - title, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ); - }), - ], - ); - }, - ), - ), - ), - ); - } - - LinkedHashMap _getLocaleOptions(BuildContext context) { - final displayLocales = AvesApp.supportedLocales.map((locale) => MapEntry(locale, LocaleTile.getLocaleName(locale))).toList()..sort((a, b) => compareAsciiUpperCase(a.value, b.value)); - - return LinkedHashMap.of({ - LocaleTile.systemLocaleOption: context.l10n.settingsSystemDefault, - ...LinkedHashMap.fromEntries(displayLocales), - }); - } -} diff --git a/lib/widgets/settings/language/locale_selection_page.dart b/lib/widgets/settings/language/locale_selection_page.dart new file mode 100644 index 000000000..b799bea65 --- /dev/null +++ b/lib/widgets/settings/language/locale_selection_page.dart @@ -0,0 +1,86 @@ +import 'dart:collection'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/aves_app.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/language/locale_tile.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class LocaleSelectionPage extends StatefulWidget { + static const routeName = '/settings/locale'; + + const LocaleSelectionPage({super.key}); + + @override + State createState() => _LocaleSelectionPageState(); +} + +class _LocaleSelectionPageState extends State { + late Locale _selectedValue; + final ValueNotifier _queryNotifier = ValueNotifier(''); + + @override + void initState() { + super.initState(); + _selectedValue = settings.locale ?? LocaleTile.systemLocaleOption; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsLanguagePageTitle), + ), + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) { + final upQuery = query.toUpperCase().trim(); + return ListView( + children: [ + QueryBar( + queryNotifier: _queryNotifier, + leadingPadding: const EdgeInsetsDirectional.only(start: 24, end: 8), + ), + ..._getLocaleOptions(context).entries.where((kv) { + if (upQuery.isEmpty) return true; + final title = kv.value; + return title.toUpperCase().contains(upQuery); + }).map((kv) { + final value = kv.key; + final title = kv.value; + return ReselectableRadioListTile( + // key is expected by test driver + key: Key(value.toString()), + value: value, + groupValue: _selectedValue, + onChanged: (v) => Navigator.pop(context, v), + reselectable: true, + title: Text( + title, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + }), + ], + ); + }, + ), + ), + ); + } + + LinkedHashMap _getLocaleOptions(BuildContext context) { + final displayLocales = AvesApp.supportedLocales.map((locale) => MapEntry(locale, LocaleTile.getLocaleName(locale))).toList()..sort((a, b) => compareAsciiUpperCase(a.value, b.value)); + + return LinkedHashMap.of({ + LocaleTile.systemLocaleOption: context.l10n.settingsSystemDefault, + ...LinkedHashMap.fromEntries(displayLocales), + }); + } +} diff --git a/lib/widgets/settings/language/locale_tile.dart b/lib/widgets/settings/language/locale_tile.dart new file mode 100644 index 000000000..b4abc61cf --- /dev/null +++ b/lib/widgets/settings/language/locale_tile.dart @@ -0,0 +1,49 @@ +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/language/locale_selection_page.dart'; +import 'package:aves/widgets/settings/language/locales.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class LocaleTile extends StatelessWidget { + static const systemLocaleOption = Locale('system'); + + const LocaleTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + // key is expected by test driver + key: const Key('tile-language'), + title: Text(context.l10n.settingsLanguageTile), + subtitle: Selector( + selector: (context, s) => settings.locale, + builder: (context, locale, child) { + return Text(locale == null ? context.l10n.settingsSystemDefault : getLocaleName(locale)); + }, + ), + onTap: () async { + final value = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: LocaleSelectionPage.routeName), + builder: (context) => const LocaleSelectionPage(), + ), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.pageTransitionAnimation * timeDilation); + if (value != null) { + settings.locale = value == systemLocaleOption ? null : value; + } + }, + ); + } + + static String getLocaleName(Locale locale) { + // the package `flutter_localized_locales` has the answer for all locales + // but it comes with 3 MB of assets + return SupportedLocales.languagesByLanguageCode[locale.languageCode] ?? locale.toString(); + } +} diff --git a/lib/widgets/settings/privacy/access_grants.dart b/lib/widgets/settings/privacy/access_grants.dart deleted file mode 100644 index b35ffdd18..000000000 --- a/lib/widgets/settings/privacy/access_grants.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:aves/services/common/services.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:flutter/material.dart'; - -class StorageAccessPage extends StatefulWidget { - static const routeName = '/settings/storage_access'; - - const StorageAccessPage({super.key}); - - @override - State createState() => _StorageAccessPageState(); -} - -class _StorageAccessPageState extends State { - late Future> _pathLoader; - List? _lastPaths; - - @override - void initState() { - super.initState(); - _load(); - } - - void _load() => _pathLoader = storageService.getGrantedDirectories(); - - @override - Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsStorageAccessPageTitle), - ), - body: SafeArea( - child: FutureBuilder>( - future: _pathLoader, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Text(snapshot.error.toString()); - } - if (snapshot.connectionState != ConnectionState.done && _lastPaths == null) { - return const SizedBox.shrink(); - } - _lastPaths = snapshot.data!..sort(); - if (_lastPaths!.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _Header(), - const Divider(), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: EmptyContent( - text: context.l10n.settingsStorageAccessEmpty, - ), - ), - ), - ], - ); - } - - return ListView( - children: [ - const _Header(), - const Divider(), - ..._lastPaths!.map((path) => ListTile( - title: Text(path), - dense: true, - trailing: IconButton( - icon: const Icon(AIcons.clear), - onPressed: () async { - await storageService.revokeDirectoryAccess(path); - _load(); - setState(() {}); - }, - tooltip: context.l10n.settingsStorageAccessRevokeTooltip, - ), - )), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _Header extends StatelessWidget { - const _Header(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - children: [ - const Icon(AIcons.info), - const SizedBox(width: 16), - Expanded(child: Text(context.l10n.settingsStorageAccessBanner)), - ], - ), - ); - } -} diff --git a/lib/widgets/settings/privacy/access_grants_page.dart b/lib/widgets/settings/privacy/access_grants_page.dart new file mode 100644 index 000000000..19612b2dd --- /dev/null +++ b/lib/widgets/settings/privacy/access_grants_page.dart @@ -0,0 +1,105 @@ +import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:flutter/material.dart'; + +class StorageAccessPage extends StatefulWidget { + static const routeName = '/settings/storage_access'; + + const StorageAccessPage({super.key}); + + @override + State createState() => _StorageAccessPageState(); +} + +class _StorageAccessPageState extends State { + late Future> _pathLoader; + List? _lastPaths; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() => _pathLoader = storageService.getGrantedDirectories(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsStorageAccessPageTitle), + ), + body: SafeArea( + child: FutureBuilder>( + future: _pathLoader, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (snapshot.connectionState != ConnectionState.done && _lastPaths == null) { + return const SizedBox.shrink(); + } + _lastPaths = snapshot.data!..sort(); + if (_lastPaths!.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _Header(), + const Divider(), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: EmptyContent( + text: context.l10n.settingsStorageAccessEmpty, + ), + ), + ), + ], + ); + } + + return ListView( + children: [ + const _Header(), + const Divider(), + ..._lastPaths!.map((path) => ListTile( + title: Text(path), + dense: true, + trailing: IconButton( + icon: const Icon(AIcons.clear), + onPressed: () async { + await storageService.revokeDirectoryAccess(path); + _load(); + setState(() {}); + }, + tooltip: context.l10n.settingsStorageAccessRevokeTooltip, + ), + )), + ], + ); + }, + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + children: [ + const Icon(AIcons.info), + const SizedBox(width: 16), + Expanded(child: Text(context.l10n.settingsStorageAccessBanner)), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/privacy/file_picker/file_picker.dart b/lib/widgets/settings/privacy/file_picker/file_picker_page.dart similarity index 58% rename from lib/widgets/settings/privacy/file_picker/file_picker.dart rename to lib/widgets/settings/privacy/file_picker/file_picker_page.dart index 973809955..432d47c25 100644 --- a/lib/widgets/settings/privacy/file_picker/file_picker.dart +++ b/lib/widgets/settings/privacy/file_picker/file_picker_page.dart @@ -10,23 +10,22 @@ import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -class FilePicker extends StatefulWidget { +class FilePickerPage extends StatefulWidget { static const routeName = '/file_picker'; - const FilePicker({super.key}); + const FilePickerPage({super.key}); @override - State createState() => _FilePickerState(); + State createState() => _FilePickerPageState(); } -class _FilePickerState extends State { +class _FilePickerPageState extends State { late VolumeRelativeDirectory _directory; List? _contents; @@ -65,77 +64,75 @@ class _FilePickerState extends State { setState(() {}); return SynchronousFuture(false); }, - child: MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(_getTitle(context)), - actions: [ - MenuIconTheme( - child: PopupMenuButton<_PickerAction>( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: _PickerAction.toggleHiddenView, - child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles), - ), - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - switch (action) { - case _PickerAction.toggleHiddenView: - settings.filePickerShowHiddenFiles = !showHidden; - setState(() {}); - break; - } + child: Scaffold( + appBar: AppBar( + title: Text(_getTitle(context)), + actions: [ + MenuIconTheme( + child: PopupMenuButton<_PickerAction>( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: _PickerAction.toggleHiddenView, + child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles), + ), + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (action) { + case _PickerAction.toggleHiddenView: + settings.filePickerShowHiddenFiles = !showHidden; + setState(() {}); + break; + } + }, + ), + ), + ], + ), + drawer: _buildDrawer(context), + body: SafeArea( + child: Column( + children: [ + SizedBox( + height: kMinInteractiveDimension, + child: CrumbLine( + directory: _directory, + onTap: (path) { + _goTo(path); + setState(() {}); }, ), ), - ], - ), - drawer: _buildDrawer(context), - body: SafeArea( - child: Column( - children: [ - SizedBox( - height: kMinInteractiveDimension, - child: CrumbLine( - directory: _directory, - onTap: (path) { - _goTo(path); - setState(() {}); - }, - ), - ), - const Divider(height: 0), - Expanded( - child: visibleContents == null - ? const SizedBox() - : visibleContents.isEmpty - ? Center( - child: EmptyContent( - icon: AIcons.folder, - text: l10n.filePickerNoItems, - ), - ) - : ListView.builder( - itemCount: visibleContents.length, - itemBuilder: (context, index) { - return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox(); - }, + const Divider(height: 0), + Expanded( + child: visibleContents == null + ? const SizedBox() + : visibleContents.isEmpty + ? Center( + child: EmptyContent( + icon: AIcons.folder, + text: l10n.filePickerNoItems, ), + ) + : ListView.builder( + itemCount: visibleContents.length, + itemBuilder: (context, index) { + return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox(); + }, + ), + ), + const Divider(height: 0), + Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.filePickerUseThisFolder, + onPressed: () => Navigator.pop(context, currentDirectoryPath), ), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.all(8), - child: AvesOutlinedButton( - label: l10n.filePickerUseThisFolder, - onPressed: () => Navigator.pop(context, currentDirectoryPath), - ), - ), - ], - ), + ), + ], ), ), ), diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items_page.dart similarity index 90% rename from lib/widgets/settings/privacy/hidden_items.dart rename to lib/widgets/settings/privacy/hidden_items_page.dart index af92aa1f6..9bab06a65 100644 --- a/lib/widgets/settings/privacy/hidden_items.dart +++ b/lib/widgets/settings/privacy/hidden_items_page.dart @@ -7,8 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/settings/privacy/file_picker/file_picker.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/file_picker_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -33,20 +32,18 @@ class HiddenItemsPage extends StatelessWidget { ), ]; - return MediaQueryDataProvider( - child: DefaultTabController( - length: tabs.length, - child: Scaffold( - appBar: AppBar( - title: Text(l10n.settingsHiddenItemsPageTitle), - bottom: TabBar( - tabs: tabs.map((t) => t.item1).toList(), - ), + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(l10n.settingsHiddenItemsPageTitle), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), ), - body: SafeArea( - child: TabBarView( - children: tabs.map((t) => t.item2).toList(), - ), + ), + body: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), ), ), ), @@ -148,8 +145,8 @@ class _HiddenPaths extends StatelessWidget { final path = await Navigator.push( context, MaterialPageRoute( - settings: const RouteSettings(name: FilePicker.routeName), - builder: (context) => const FilePicker(), + settings: const RouteSettings(name: FilePickerPage.routeName), + builder: (context) => const FilePickerPage(), ), ); // wait for the dialog to hide as applying the change may block the UI diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 84ac9ea6f..4cbe168fe 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -9,8 +9,8 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; -import 'package:aves/widgets/settings/privacy/access_grants.dart'; -import 'package:aves/widgets/settings/privacy/hidden_items.dart'; +import 'package:aves/widgets/settings/privacy/access_grants_page.dart'; +import 'package:aves/widgets/settings/privacy/hidden_items_page.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/settings/screen_saver_settings_page.dart b/lib/widgets/settings/screen_saver_settings_page.dart index b1c1d64e2..5e252e4c7 100644 --- a/lib/widgets/settings/screen_saver_settings_page.dart +++ b/lib/widgets/settings/screen_saver_settings_page.dart @@ -4,7 +4,6 @@ import 'package:aves/model/settings/enums/slideshow_video_playback.dart'; import 'package:aves/model/settings/enums/viewer_transition.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/common/collection_tile.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:flutter/material.dart'; @@ -18,55 +17,53 @@ class ScreenSaverSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.settingsScreenSaverPageTitle), - ), - body: SafeArea( - child: ListView( - children: [ - SettingsSwitchListTile( - selector: (context, s) => s.screenSaverFillScreen, - onChanged: (v) => settings.screenSaverFillScreen = v, - title: l10n.settingsSlideshowFillScreen, - ), - SettingsSwitchListTile( - selector: (context, s) => s.screenSaverAnimatedZoomEffect, - onChanged: (v) => settings.screenSaverAnimatedZoomEffect = v, - title: l10n.settingsSlideshowAnimatedZoomEffect, - ), - SettingsSelectionListTile( - values: ViewerTransition.values, - getName: (context, v) => v.getName(context), - selector: (context, s) => s.screenSaverTransition, - onSelection: (v) => settings.screenSaverTransition = v, - tileTitle: l10n.settingsSlideshowTransitionTile, - ), - SettingsDurationListTile( - selector: (context, s) => s.screenSaverInterval, - onChanged: (v) => settings.screenSaverInterval = v, - title: l10n.settingsSlideshowIntervalTile, - ), - SettingsSelectionListTile( - values: SlideshowVideoPlayback.values, - getName: (context, v) => v.getName(context), - selector: (context, s) => s.screenSaverVideoPlayback, - onSelection: (v) => settings.screenSaverVideoPlayback = v, - tileTitle: l10n.settingsSlideshowVideoPlaybackTile, - dialogTitle: l10n.settingsSlideshowVideoPlaybackDialogTitle, - ), - Selector>( - selector: (context, s) => s.screenSaverCollectionFilters, - builder: (context, filters, child) { - return SettingsCollectionTile( - filters: filters, - onSelection: (v) => settings.screenSaverCollectionFilters = v, - ); - }, - ), - ], - ), + return Scaffold( + appBar: AppBar( + title: Text(l10n.settingsScreenSaverPageTitle), + ), + body: SafeArea( + child: ListView( + children: [ + SettingsSwitchListTile( + selector: (context, s) => s.screenSaverFillScreen, + onChanged: (v) => settings.screenSaverFillScreen = v, + title: l10n.settingsSlideshowFillScreen, + ), + SettingsSwitchListTile( + selector: (context, s) => s.screenSaverAnimatedZoomEffect, + onChanged: (v) => settings.screenSaverAnimatedZoomEffect = v, + title: l10n.settingsSlideshowAnimatedZoomEffect, + ), + SettingsSelectionListTile( + values: ViewerTransition.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.screenSaverTransition, + onSelection: (v) => settings.screenSaverTransition = v, + tileTitle: l10n.settingsSlideshowTransitionTile, + ), + SettingsDurationListTile( + selector: (context, s) => s.screenSaverInterval, + onChanged: (v) => settings.screenSaverInterval = v, + title: l10n.settingsSlideshowIntervalTile, + ), + SettingsSelectionListTile( + values: SlideshowVideoPlayback.values, + getName: (context, v) => v.getName(context), + selector: (context, s) => s.screenSaverVideoPlayback, + onSelection: (v) => settings.screenSaverVideoPlayback = v, + tileTitle: l10n.settingsSlideshowVideoPlaybackTile, + dialogTitle: l10n.settingsSlideshowVideoPlaybackDialogTitle, + ), + Selector>( + selector: (context, s) => s.screenSaverCollectionFilters, + builder: (context, filters, child) { + return SettingsCollectionTile( + filters: filters, + onSelection: (v) => settings.screenSaverCollectionFilters = v, + ); + }, + ), + ], ), ), ); diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 4ef55b01a..0d53a56f3 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -63,73 +63,71 @@ class _SettingsPageState extends State with FeedbackMixin { Widget build(BuildContext context) { final theme = Theme.of(context); final durations = context.watch(); - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: InteractiveAppBarTitle( - onTap: () => _goToSearch(context), - child: Text(context.l10n.settingsPageTitle), - ), - actions: [ - IconButton( - icon: const Icon(AIcons.search), - onPressed: () => _goToSearch(context), - tooltip: MaterialLocalizations.of(context).searchFieldLabel, - ), - if (!device.isTelevision) - MenuIconTheme( - child: PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: SettingsAction.export, - child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)), - ), - PopupMenuItem( - value: SettingsAction.import, - child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)), - ), - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - _onActionSelected(action); - }, - ), - ), - ], + return Scaffold( + appBar: AppBar( + title: InteractiveAppBarTitle( + onTap: () => _goToSearch(context), + child: Text(context.l10n.settingsPageTitle), ), - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyMedium: const TextStyle(fontSize: 12), - ), + actions: [ + IconButton( + icon: const Icon(AIcons.search), + onPressed: () => _goToSearch(context), + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + ), + if (!device.isTelevision) + MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: SettingsAction.export, + child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)), + ), + PopupMenuItem( + value: SettingsAction.import, + child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)), + ), + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + _onActionSelected(action); + }, ), - child: AnimationLimiter( - child: Selector( - selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), - builder: (context, mqPaddingBottom, child) { - return ListView( - padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), - children: AnimationConfiguration.toStaggeredList( - duration: durations.staggeredAnimation, - delay: durations.staggeredAnimationDelay * timeDilation, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), + ), + ], + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: Theme( + data: theme.copyWith( + textTheme: theme.textTheme.copyWith( + // dense style font for tile subtitles, without modifying title font + bodyMedium: const TextStyle(fontSize: 12), + ), + ), + child: AnimationLimiter( + child: Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return ListView( + padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), + children: AnimationConfiguration.toStaggeredList( + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, ), - children: sections.map((v) => v.build(context, _expandedNotifier)).toList(), ), - ); - }), - ), + children: sections.map((v) => v.build(context, _expandedNotifier)).toList(), + ), + ); + }), ), ), ), diff --git a/lib/widgets/settings/thumbnails/collection_actions_editor.dart b/lib/widgets/settings/thumbnails/collection_actions_editor_page.dart similarity index 78% rename from lib/widgets/settings/thumbnails/collection_actions_editor.dart rename to lib/widgets/settings/thumbnails/collection_actions_editor_page.dart index 38303fe73..24d3e0d38 100644 --- a/lib/widgets/settings/thumbnails/collection_actions_editor.dart +++ b/lib/widgets/settings/thumbnails/collection_actions_editor_page.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart'; import 'package:flutter/material.dart'; import 'package:tuple/tuple.dart'; @@ -42,20 +41,18 @@ class CollectionActionEditorPage extends StatelessWidget { ), ]; - return MediaQueryDataProvider( - child: DefaultTabController( - length: tabs.length, - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsCollectionQuickActionEditorPageTitle), - bottom: TabBar( - tabs: tabs.map((t) => t.item1).toList(), - ), + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsCollectionQuickActionEditorPageTitle), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), ), - body: SafeArea( - child: TabBarView( - children: tabs.map((t) => t.item2).toList(), - ), + ), + body: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), ), ), ), diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index 040f660c0..fcf59343d 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -4,7 +4,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; -import 'package:aves/widgets/settings/thumbnails/collection_actions_editor.dart'; +import 'package:aves/widgets/settings/thumbnails/collection_actions_editor_page.dart'; import 'package:aves/widgets/settings/thumbnails/overlay.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/settings/video/video_settings_page.dart b/lib/widgets/settings/video/video_settings_page.dart index edacc22d4..21187d01b 100644 --- a/lib/widgets/settings/video/video_settings_page.dart +++ b/lib/widgets/settings/video/video_settings_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:aves/widgets/settings/video/video.dart'; import 'package:flutter/material.dart'; @@ -19,30 +18,28 @@ class _VideoSettingsPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsVideoPageTitle), - ), - body: Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyMedium: const TextStyle(fontSize: 12), - ), + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsVideoPageTitle), + ), + body: Theme( + data: theme.copyWith( + textTheme: theme.textTheme.copyWith( + // dense style font for tile subtitles, without modifying title font + bodyMedium: const TextStyle(fontSize: 12), ), - child: SafeArea( - child: FutureBuilder>( - future: Future.value(VideoSection(standalonePage: true).tiles(context)), - builder: (context, snapshot) { - final tiles = snapshot.data; - if (tiles == null) return const SizedBox(); + ), + child: SafeArea( + child: FutureBuilder>( + future: Future.value(VideoSection(standalonePage: true).tiles(context)), + builder: (context, snapshot) { + final tiles = snapshot.data; + if (tiles == null) return const SizedBox(); - return ListView( - children: tiles.map((v) => v.build(context)).toList(), - ); - }, - ), + return ListView( + children: tiles.map((v) => v.build(context)).toList(), + ); + }, ), ), ), diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index f4bb72074..c1d2ea247 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -19,7 +19,6 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/stats/date/histogram.dart'; import 'package:aves/widgets/stats/filter_table.dart'; @@ -224,20 +223,18 @@ class _StatsPageState extends State { } } - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(l10n.statsPageTitle), - ), - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: child, + return Scaffold( + appBar: AppBar( + title: Text(l10n.statsPageTitle), + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, ), + child: child, ), ), ), @@ -355,30 +352,28 @@ class StatsTopPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: Builder(builder: (context) { - return NotificationListener( - onNotification: (notification) { - onFilterSelection(notification.reversedFilter); - return true; - }, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 8) + - EdgeInsets.only( - bottom: context.select((mq) => mq.effectiveBottomPadding), - ), - child: tableBuilder(context), - ), - ); - }), - ), + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: Builder(builder: (context) { + return NotificationListener( + onNotification: (notification) { + onFilterSelection(notification.reversedFilter); + return true; + }, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8) + + EdgeInsets.only( + bottom: context.select((mq) => mq.effectiveBottomPadding), + ), + child: tableBuilder(context), + ), + ); + }), ), ), ); diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index c990971b5..1a1eea9b9 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -1,7 +1,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; @@ -42,26 +41,24 @@ class _EntryViewerPageState extends State { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: ViewStateConductorProvider( - child: VideoConductorProvider( - child: MultiPageConductorProvider( - child: EntryViewerStack( - collection: widget.collection, - initialEntry: widget.initialEntry, - viewerController: _viewerController, - ), + return Scaffold( + body: ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: EntryViewerStack( + collection: widget.collection, + initialEntry: widget.initialEntry, + viewerController: _viewerController, ), ), ), - backgroundColor: Navigator.canPop(context) - ? Colors.transparent - : Theme.of(context).brightness == Brightness.dark - ? Colors.black - : Colors.white, - resizeToAvoidBottomInset: false, ), + backgroundColor: Navigator.canPop(context) + ? Colors.transparent + : Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, + resizeToAvoidBottomInset: false, ); } } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 18cc43c7a..22c9516e3 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -7,7 +7,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; @@ -46,51 +45,49 @@ class _InfoPageState extends State { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: NotificationListener( - onNotification: _handleTopScroll, - child: Selector( - selector: (context, mq) => mq.size.width, - builder: (context, mqWidth, child) { - return ValueListenableBuilder( - valueListenable: widget.entryNotifier, - builder: (context, mainEntry, child) { - if (mainEntry == null) return const SizedBox(); + return Scaffold( + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: NotificationListener( + onNotification: _handleTopScroll, + child: Selector( + selector: (context, mq) => mq.size.width, + builder: (context, mqWidth, child) { + return ValueListenableBuilder( + valueListenable: widget.entryNotifier, + builder: (context, mainEntry, child) { + if (mainEntry == null) return const SizedBox(); - Widget _buildContent({AvesEntry? pageEntry}) { - final targetEntry = pageEntry ?? mainEntry; - return EmbeddedDataOpener( + Widget _buildContent({AvesEntry? pageEntry}) { + final targetEntry = pageEntry ?? mainEntry; + return EmbeddedDataOpener( + entry: targetEntry, + child: _InfoPageContent( + collection: widget.collection, entry: targetEntry, - child: _InfoPageContent( - collection: widget.collection, - entry: targetEntry, - isScrollingNotifier: widget.isScrollingNotifier, - scrollController: _scrollController, - split: mqWidth > splitScreenWidthThreshold, - goToViewer: _goToViewer, - ), - ); - } + isScrollingNotifier: widget.isScrollingNotifier, + scrollController: _scrollController, + split: mqWidth > splitScreenWidthThreshold, + goToViewer: _goToViewer, + ), + ); + } - return mainEntry.isBurst - ? PageEntryBuilder( - multiPageController: context.read().getController(mainEntry), - builder: (pageEntry) => _buildContent(pageEntry: pageEntry), - ) - : _buildContent(); - }, - ); - }, - ), + return mainEntry.isBurst + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); + }, + ); + }, ), ), ), - resizeToAvoidBottomInset: false, ), + resizeToAvoidBottomInset: false, ); } diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index 6a6616f6b..afc7a4a8f 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -8,7 +8,6 @@ import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -60,89 +59,87 @@ class _PanoramaPageState extends State { _onLeave(); return SynchronousFuture(true); }, - child: MediaQueryDataProvider( - child: Scaffold( - body: Stack( - children: [ - ValueListenableBuilder( - valueListenable: _sensorControl, - builder: (context, sensorControl, child) { - void onTap(longitude, latitude, tilt) => _overlayVisible.value = !_overlayVisible.value; - final imageChild = child as Image; + child: Scaffold( + body: Stack( + children: [ + ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + void onTap(longitude, latitude, tilt) => _overlayVisible.value = !_overlayVisible.value; + final imageChild = child as Image; - if (info.hasCroppedArea) { - final croppedArea = info.croppedAreaRect!; - final fullSize = info.fullPanoSize!; - final longitude = ((croppedArea.left + croppedArea.width / 2) / fullSize.width - 1 / 2) * 360; - return Panorama( - longitude: longitude, - sensorControl: sensorControl, - croppedArea: croppedArea, - croppedFullWidth: fullSize.width, - croppedFullHeight: fullSize.height, - onTap: onTap, - child: imageChild, - ); - } else { - return Panorama( - sensorControl: sensorControl, - onTap: onTap, - child: imageChild, - ); - } - }, - child: Image( - image: entry.uriImage, - ), + if (info.hasCroppedArea) { + final croppedArea = info.croppedAreaRect!; + final fullSize = info.fullPanoSize!; + final longitude = ((croppedArea.left + croppedArea.width / 2) / fullSize.width - 1 / 2) * 360; + return Panorama( + longitude: longitude, + sensorControl: sensorControl, + croppedArea: croppedArea, + croppedFullWidth: fullSize.width, + croppedFullHeight: fullSize.height, + onTap: onTap, + child: imageChild, + ); + } else { + return Panorama( + sensorControl: sensorControl, + onTap: onTap, + child: imageChild, + ); + } + }, + child: Image( + image: entry.uriImage, ), - Positioned( - right: 0, - bottom: 0, - child: TooltipTheme( - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: ValueListenableBuilder( - valueListenable: _overlayVisible, - builder: (context, overlayVisible, child) { - return Visibility( - visible: overlayVisible, - child: Selector( - selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), - builder: (context, mqPaddingBottom, child) { - return SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), - child: child, - ), - ); - }, - child: OverlayButton( - child: ValueListenableBuilder( - valueListenable: _sensorControl, - builder: (context, sensorControl, child) { - return IconButton( - icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControlEnabled : AIcons.sensorControlDisabled), - onPressed: _toggleSensor, - tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, - ); - }, + ), + Positioned( + right: 0, + bottom: 0, + child: TooltipTheme( + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: ValueListenableBuilder( + valueListenable: _overlayVisible, + builder: (context, overlayVisible, child) { + return Visibility( + visible: overlayVisible, + child: Selector( + selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), + builder: (context, mqPaddingBottom, child) { + return SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8) + EdgeInsets.only(bottom: mqPaddingBottom), + child: child, ), + ); + }, + child: OverlayButton( + child: ValueListenableBuilder( + valueListenable: _sensorControl, + builder: (context, sensorControl, child) { + return IconButton( + icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControlEnabled : AIcons.sensorControlDisabled), + onPressed: _toggleSensor, + tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, + ); + }, ), ), - ); - }, - ), + ), + ); + }, ), ), - const TopGestureAreaProtector(), - const SideGestureAreaProtector(), - const BottomGestureAreaProtector(), - ], - ), - resizeToAvoidBottomInset: false, + ), + const TopGestureAreaProtector(), + const SideGestureAreaProtector(), + const BottomGestureAreaProtector(), + ], ), + resizeToAvoidBottomInset: false, ), ); } diff --git a/lib/widgets/viewer/screen_saver_page.dart b/lib/widgets/viewer/screen_saver_page.dart index febfa2d00..30c1064c4 100644 --- a/lib/widgets/viewer/screen_saver_page.dart +++ b/lib/widgets/viewer/screen_saver_page.dart @@ -6,7 +6,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; @@ -94,10 +93,8 @@ class _ScreenSaverPageState extends State with WidgetsBindingOb } } - return MediaQueryDataProvider( - child: Scaffold( - body: child, - ), + return Scaffold( + body: child, ); } diff --git a/lib/widgets/viewer/slideshow_page.dart b/lib/widgets/viewer/slideshow_page.dart index 7c4eccb58..ca50abb53 100644 --- a/lib/widgets/viewer/slideshow_page.dart +++ b/lib/widgets/viewer/slideshow_page.dart @@ -9,7 +9,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; @@ -60,32 +59,30 @@ class _SlideshowPageState extends State { final entries = _slideshowCollection.sortedEntries; return ListenableProvider>.value( value: ValueNotifier(AppMode.slideshow), - child: MediaQueryDataProvider( - child: Scaffold( - body: entries.isEmpty - ? EmptyContent( - icon: AIcons.image, - text: context.l10n.collectionEmptyImages, - alignment: Alignment.center, - ) - : ViewStateConductorProvider( - child: VideoConductorProvider( - child: MultiPageConductorProvider( - child: NotificationListener( - onNotification: (notification) { - _onActionSelected(notification.action); - return true; - }, - child: EntryViewerStack( - collection: _slideshowCollection, - initialEntry: entries.first, - viewerController: _viewerController, - ), + child: Scaffold( + body: entries.isEmpty + ? EmptyContent( + icon: AIcons.image, + text: context.l10n.collectionEmptyImages, + alignment: Alignment.center, + ) + : ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: NotificationListener( + onNotification: (notification) { + _onActionSelected(notification.action); + return true; + }, + child: EntryViewerStack( + collection: _slideshowCollection, + initialEntry: entries.first, + viewerController: _viewerController, ), ), ), ), - ), + ), ), ); } diff --git a/lib/widgets/wallpaper_page.dart b/lib/widgets/wallpaper_page.dart index 93ee8db9e..c3c1ba15c 100644 --- a/lib/widgets/wallpaper_page.dart +++ b/lib/widgets/wallpaper_page.dart @@ -5,7 +5,6 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/common/basic/insets.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/controller.dart'; import 'package:aves/widgets/viewer/entry_horizontal_pager.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; @@ -35,22 +34,20 @@ class WallpaperPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: entry != null - ? ViewStateConductorProvider( - child: VideoConductorProvider( - child: MultiPageConductorProvider( - child: EntryEditor( - entry: entry!, - ), + return Scaffold( + body: entry != null + ? ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: EntryEditor( + entry: entry!, ), ), - ) - : const SizedBox(), - backgroundColor: Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white, - resizeToAvoidBottomInset: false, - ), + ), + ) + : const SizedBox(), + backgroundColor: Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white, + resizeToAvoidBottomInset: false, ); } } diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index f043aa223..ca3298ef8 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -7,7 +7,6 @@ import 'package:aves/widgets/common/basic/markdown_container.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/buttons.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -47,67 +46,65 @@ class _WelcomePageState extends State { @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: SafeArea( - child: Center( - child: FutureBuilder( - future: _termsLoader, - builder: (context, snapshot) { - if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox(); - final terms = snapshot.data!; - final durations = context.watch(); - final isPortrait = context.select((mq) => mq.orientation) == Orientation.portrait; - return Column( - mainAxisSize: MainAxisSize.min, - children: _toStaggeredList( - duration: durations.staggeredAnimation, - delay: durations.staggeredAnimationDelay * timeDilation, - childAnimationBuilder: (child) => SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: child, - ), + return Scaffold( + body: SafeArea( + child: Center( + child: FutureBuilder( + future: _termsLoader, + builder: (context, snapshot) { + if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox(); + final terms = snapshot.data!; + final durations = context.watch(); + final isPortrait = context.select((mq) => mq.orientation) == Orientation.portrait; + return Column( + mainAxisSize: MainAxisSize.min, + children: _toStaggeredList( + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, ), - children: [ - ..._buildHeader(context, isPortrait: isPortrait), - if (isPortrait) ...[ - Flexible( - child: MarkdownContainer( - data: terms, - textDirection: termsDirection, - ), - ), - const SizedBox(height: 16), - ..._buildControls(context), - ] else - Flexible( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Flexible( - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: MarkdownContainer( - data: terms, - textDirection: termsDirection, - ), - ), - ), - Flexible( - child: ListView( - shrinkWrap: true, - children: _buildControls(context), - ), - ), - ], - ), - ) - ], ), - ); - }, - ), + children: [ + ..._buildHeader(context, isPortrait: isPortrait), + if (isPortrait) ...[ + Flexible( + child: MarkdownContainer( + data: terms, + textDirection: termsDirection, + ), + ), + const SizedBox(height: 16), + ..._buildControls(context), + ] else + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MarkdownContainer( + data: terms, + textDirection: termsDirection, + ), + ), + ), + Flexible( + child: ListView( + shrinkWrap: true, + children: _buildControls(context), + ), + ), + ], + ), + ) + ], + ), + ); + }, ), ), ),