From af9edebf864248f86a29d41c879b750f43b1fa42 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 7 Sep 2020 11:40:00 +0900 Subject: [PATCH] shortcut to search page --- .../deckers/thibault/aves/MainActivity.java | 14 +- lib/model/settings/home_page.dart | 4 +- lib/widgets/collection/app_bar.dart | 18 +- .../collection/search/search_delegate.dart | 186 ++++++++++++++++-- lib/widgets/collection/search_page.dart | 127 ++++++++++++ .../entry_action_delegate.dart | 5 +- lib/widgets/filter_grids/albums_page.dart | 32 +-- .../filter_grids/decorated_filter_chip.dart | 3 +- .../filter_grids/filter_grid_page.dart | 1 + lib/widgets/filter_grids/search_button.dart | 29 +++ lib/widgets/fullscreen/fullscreen_body.dart | 7 +- lib/widgets/home_page.dart | 31 ++- 12 files changed, 389 insertions(+), 68 deletions(-) create mode 100644 lib/widgets/collection/search_page.dart create mode 100644 lib/widgets/filter_grids/search_button.dart diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index c5df45489..58ae2c2ee 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -87,21 +87,21 @@ public class MainActivity extends FlutterActivity { private void setupShortcuts() { ShortcutManager shortcutManager = getSystemService(ShortcutManager.class); - Intent searchIntent = new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class); - searchIntent.putExtra("page", "search"); + // do not use 'route' as extra key, as the Flutter framework acts on it + ShortcutInfo search = new ShortcutInfo.Builder(this, "search") .setShortLabel(getString(R.string.search_shortcut_short_label)) .setIcon(Icon.createWithResource(this, R.drawable.ic_outline_search)) - .setIntent(searchIntent) + .setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class) + .putExtra("page", "/search")) .build(); - Intent videosIntent = new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class); - videosIntent.putExtra("page", "collection"); - videosIntent.putExtra("filters", new String[]{"anyVideo"}); ShortcutInfo videos = new ShortcutInfo.Builder(this, "videos") .setShortLabel(getString(R.string.videos_shortcut_short_label)) .setIcon(Icon.createWithResource(this, R.drawable.ic_outline_movie)) - .setIntent(videosIntent) + .setIntent(new Intent(Intent.ACTION_MAIN, null, this, MainActivity.class) + .putExtra("page", "/collection") + .putExtra("filters", new String[]{"anyVideo"})) .build(); shortcutManager.setDynamicShortcuts(Arrays.asList(videos, search)); } diff --git a/lib/model/settings/home_page.dart b/lib/model/settings/home_page.dart index 480c4e896..bc5c29b75 100644 --- a/lib/model/settings/home_page.dart +++ b/lib/model/settings/home_page.dart @@ -1,7 +1,7 @@ import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; -enum HomePageSetting { collection, albums, search } +enum HomePageSetting { collection, albums } extension ExtraHomePageSetting on HomePageSetting { String get name { @@ -10,8 +10,6 @@ extension ExtraHomePageSetting on HomePageSetting { return 'Collection'; case HomePageSetting.albums: return 'Albums'; - case HomePageSetting.search: - return 'Search'; default: return toString(); } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 8605d4636..a89e81e47 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -295,7 +295,7 @@ class _CollectionAppBarState extends State with SingleTickerPr collection.clearSelection(); break; case CollectionAction.stats: - unawaited(_goToStats()); + _goToStats(); break; case CollectionAction.group: final value = await showDialog( @@ -338,14 +338,18 @@ class _CollectionAppBarState extends State with SingleTickerPr } void _goToSearch() { - showSearch( - context: context, - delegate: ImageSearchDelegate(collection.source, collection.addFilter), - ); + Navigator.push( + context, + SearchPageRoute( + delegate: ImageSearchDelegate( + source: collection.source, + parentCollection: collection, + ), + )); } - Future _goToStats() { - return Navigator.push( + void _goToStats() { + Navigator.push( context, MaterialPageRoute( settings: RouteSettings(name: StatsPage.routeName), diff --git a/lib/widgets/collection/search/search_delegate.dart b/lib/widgets/collection/search/search_delegate.dart index ed570fc65..b63699f09 100644 --- a/lib/widgets/collection/search/search_delegate.dart +++ b/lib/widgets/collection/search/search_delegate.dart @@ -6,40 +6,46 @@ import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/mime_types.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/search/expandable_filter_row.dart'; +import 'package:aves/widgets/collection/search_page.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -class ImageSearchDelegate extends SearchDelegate { +class ImageSearchDelegate { final CollectionSource source; final ValueNotifier expandedSectionNotifier = ValueNotifier(null); - final FilterCallback onSelection; + final CollectionLens parentCollection; - ImageSearchDelegate(this.source, this.onSelection); + ImageSearchDelegate({@required this.source, this.parentCollection}); - @override ThemeData appBarTheme(BuildContext context) { return Theme.of(context); } - @override Widget buildLeading(BuildContext context) { - return IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, - progress: transitionAnimation, - ), - onPressed: () => _select(context, null), - tooltip: 'Back', - ); + return Navigator.canPop(context) + ? IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: transitionAnimation, + ), + onPressed: () => _goBack(context), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ) + : CloseButton( + onPressed: SystemNavigator.pop, + ); } - @override List buildActions(BuildContext context) { return [ if (query.isNotEmpty) @@ -54,7 +60,6 @@ class ImageSearchDelegate extends SearchDelegate { ]; } - @override Widget buildSuggestions(BuildContext context) { final upQuery = query.trim().toUpperCase(); bool containQuery(String s) => s.toUpperCase().contains(upQuery); @@ -137,7 +142,6 @@ class ImageSearchDelegate extends SearchDelegate { ); } - @override Widget buildResults(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { // `buildResults` is called in the build phase, @@ -154,14 +158,160 @@ class ImageSearchDelegate extends SearchDelegate { } void _select(BuildContext context, CollectionFilter filter) { + if (parentCollection != null) { + _applyToParentCollectionPage(context, filter); + } else { + _goToCollectionPage(context, filter); + } + } + + void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { if (filter != null) { - onSelection(filter); + parentCollection.addFilter(filter); } // we post closing the search page after applying the filter selection // so that hero animation target is ready in the `FilterBar`, // even when the target is a child of an `AnimatedList` WidgetsBinding.instance.addPostFrameCallback((_) { - close(context, null); + _goBack(context); }); } + + void _goBack(BuildContext context) { + _clean(); + Navigator.of(context).pop(); + } + + void _goToCollectionPage(BuildContext context, CollectionFilter filter) { + _clean(); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage(CollectionLens( + source: source, + filters: [filter], + groupFactor: settings.collectionGroupFactor, + sortFactor: settings.collectionSortFactor, + )), + ), + settings.navRemoveRoutePredicate(CollectionPage.routeName), + ); + } + + void _clean() { + currentBody = null; + focusNode?.unfocus(); + } + + // adapted from `SearchDelegate` + + void showResults(BuildContext context) { + focusNode?.unfocus(); + currentBody = SearchBody.results; + } + + void showSuggestions(BuildContext context) { + assert(focusNode != null, '_focusNode must be set by route before showSuggestions is called.'); + focusNode.requestFocus(); + currentBody = SearchBody.suggestions; + } + + Animation get transitionAnimation => proxyAnimation; + + FocusNode focusNode; + + final TextEditingController queryTextController = TextEditingController(); + + final ProxyAnimation proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation); + + String get query => queryTextController.text; + + set query(String value) { + assert(query != null); + queryTextController.text = value; + } + + final ValueNotifier currentBodyNotifier = ValueNotifier(null); + + SearchBody get currentBody => currentBodyNotifier.value; + + set currentBody(SearchBody value) { + currentBodyNotifier.value = value; + } + + SearchPageRoute route; +} + +// adapted from `SearchDelegate` +enum SearchBody { suggestions, results } + +// adapted from `SearchDelegate` +class SearchPageRoute extends PageRoute { + SearchPageRoute({ + @required this.delegate, + }) : assert(delegate != null), + super(settings: RouteSettings(name: SearchPage.routeName)) { + assert( + delegate.route == null, + 'The ${delegate.runtimeType} instance is currently used by another active ' + 'search. Please close that search by calling close() on the SearchDelegate ' + 'before openening another search with the same delegate instance.', + ); + delegate.route = this; + } + + final ImageSearchDelegate delegate; + + @override + Color get barrierColor => null; + + @override + String get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 300); + + @override + bool get maintainState => false; + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: child, + ); + } + + @override + Animation createAnimation() { + final animation = super.createAnimation(); + delegate.proxyAnimation.parent = animation; + return animation; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return SearchPage( + delegate: delegate, + animation: animation, + ); + } + + @override + void didComplete(T result) { + super.didComplete(result); + assert(delegate.route == this); + delegate.route = null; + delegate.currentBody = null; + } } diff --git a/lib/widgets/collection/search_page.dart b/lib/widgets/collection/search_page.dart new file mode 100644 index 000000000..c131ef739 --- /dev/null +++ b/lib/widgets/collection/search_page.dart @@ -0,0 +1,127 @@ +import 'package:aves/widgets/collection/search/search_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class SearchPage extends StatefulWidget { + static const routeName = '/search'; + + final ImageSearchDelegate delegate; + final Animation animation; + + const SearchPage({ + this.delegate, + this.animation, + }); + + @override + _SearchPageState createState() => _SearchPageState(); +} + +class _SearchPageState extends State { + FocusNode focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + widget.delegate.queryTextController.addListener(_onQueryChanged); + widget.animation.addStatusListener(_onAnimationStatusChanged); + widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); + focusNode.addListener(_onFocusChanged); + widget.delegate.focusNode = focusNode; + } + + @override + void dispose() { + super.dispose(); + widget.delegate.queryTextController.removeListener(_onQueryChanged); + widget.animation.removeStatusListener(_onAnimationStatusChanged); + widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate.focusNode = null; + focusNode.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus status) { + if (status != AnimationStatus.completed) { + return; + } + widget.animation.removeStatusListener(_onAnimationStatusChanged); + focusNode.requestFocus(); + } + + @override + void didUpdateWidget(SearchPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + oldWidget.delegate.queryTextController.removeListener(_onQueryChanged); + widget.delegate.queryTextController.addListener(_onQueryChanged); + oldWidget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); + oldWidget.delegate.focusNode = null; + widget.delegate.focusNode = focusNode; + } + } + + void _onFocusChanged() { + if (focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { + widget.delegate.showSuggestions(context); + } + } + + void _onQueryChanged() { + setState(() { + // rebuild ourselves because query changed. + }); + } + + void _onSearchBodyChanged() { + setState(() { + // rebuild ourselves because search body changed. + }); + } + + @override + Widget build(BuildContext context) { + final theme = widget.delegate.appBarTheme(context); + Widget body; + switch (widget.delegate.currentBody) { + case SearchBody.suggestions: + body = KeyedSubtree( + key: ValueKey(SearchBody.suggestions), + child: widget.delegate.buildSuggestions(context), + ); + break; + case SearchBody.results: + body = KeyedSubtree( + key: ValueKey(SearchBody.results), + child: widget.delegate.buildResults(context), + ); + break; + } + return Scaffold( + appBar: AppBar( + backgroundColor: theme.primaryColor, + iconTheme: theme.primaryIconTheme, + textTheme: theme.primaryTextTheme, + brightness: theme.primaryColorBrightness, + leading: widget.delegate.buildLeading(context), + title: TextField( + controller: widget.delegate.queryTextController, + focusNode: focusNode, + style: theme.textTheme.headline6, + textInputAction: TextInputAction.search, + onSubmitted: (_) => widget.delegate.showResults(context), + decoration: InputDecoration( + border: InputBorder.none, + hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintStyle: theme.inputDecorationTheme.hintStyle, + ), + ), + actions: widget.delegate.buildActions(context), + ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: body, + ), + ); + } +} diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index 4aac59c72..d3b167365 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/android_app_service.dart'; @@ -12,6 +10,7 @@ import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/debug.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pdf; @@ -154,7 +153,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { } } else { // leave viewer - exit(0); + unawaited(SystemNavigator.pop()); } } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index a95766ed1..dc6539b94 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -11,6 +11,7 @@ import 'package:aves/widgets/common/aves_selection_dialog.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; +import 'package:aves/widgets/filter_grids/search_button.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -36,7 +37,7 @@ class AlbumListPage extends StatelessWidget { return FilterNavigationPage( source: source, title: 'Albums', - actions: _buildActions(), + actions: _buildActions(context), filterEntries: getAlbumEntries(source), filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), emptyBuilder: () => EmptyContent( @@ -51,22 +52,21 @@ class AlbumListPage extends StatelessWidget { ); } - List _buildActions() { + List _buildActions(BuildContext context) { return [ - Builder( - builder: (context) => PopupMenuButton( - key: Key('appbar-menu-button'), - itemBuilder: (context) { - return [ - PopupMenuItem( - key: Key('menu-sort'), - value: ChipAction.sort, - child: MenuRow(text: 'Sort...', icon: AIcons.sort), - ), - ]; - }, - onSelected: (action) => _onChipActionSelected(context, action), - ), + SearchButton(source), + PopupMenuButton( + key: Key('appbar-menu-button'), + itemBuilder: (context) { + return [ + PopupMenuItem( + key: Key('menu-sort'), + value: ChipAction.sort, + child: MenuRow(text: 'Sort...', icon: AIcons.sort), + ), + ]; + }, + onSelected: (action) => _onChipActionSelected(context, action), ), ]; } diff --git a/lib/widgets/filter_grids/decorated_filter_chip.dart b/lib/widgets/filter_grids/decorated_filter_chip.dart index 53f9d4e16..210311562 100644 --- a/lib/widgets/filter_grids/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/decorated_filter_chip.dart @@ -19,11 +19,12 @@ class DecoratedFilterChip extends StatelessWidget { final FilterCallback onPressed; const DecoratedFilterChip({ + Key key, @required this.source, @required this.filter, @required this.entry, @required this.onPressed, - }); + }) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/widgets/filter_grids/filter_grid_page.dart b/lib/widgets/filter_grids/filter_grid_page.dart index 7f184b55a..5daaa0473 100644 --- a/lib/widgets/filter_grids/filter_grid_page.dart +++ b/lib/widgets/filter_grids/filter_grid_page.dart @@ -119,6 +119,7 @@ class FilterGridPage extends StatelessWidget { (context, i) { final key = filterKeys[i]; final child = DecoratedFilterChip( + key: Key(key), source: source, filter: filterBuilder(key), entry: filterEntries[key], diff --git a/lib/widgets/filter_grids/search_button.dart b/lib/widgets/filter_grids/search_button.dart new file mode 100644 index 000000000..6a8577700 --- /dev/null +++ b/lib/widgets/filter_grids/search_button.dart @@ -0,0 +1,29 @@ +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/collection/search/search_delegate.dart'; +import 'package:aves/widgets/common/icons.dart'; +import 'package:flutter/material.dart'; + +class SearchButton extends StatelessWidget { + final CollectionSource source; + + const SearchButton(this.source); + + @override + Widget build(BuildContext context) { + return IconButton( + key: Key('search-button'), + icon: Icon(AIcons.search), + onPressed: () => _goToSearch(context), + ); + } + + void _goToSearch(BuildContext context) { + Navigator.push( + context, + SearchPageRoute( + delegate: ImageSearchDelegate( + source: source, + ), + )); + } +} diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 352902072..8e3515585 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -325,11 +325,12 @@ class FullscreenBodyState extends State with SingleTickerProvide } void _onLeave() { - if (!Navigator.canPop(context)) { + if (Navigator.canPop(context)) { + _showSystemUI(); + } else { // exit app when trying to pop a fullscreen page that is a viewer for a single entry - exit(0); + SystemNavigator.pop(); } - _showSystemUI(); } // system UI diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 936c64af4..b09537df5 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -10,6 +10,8 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/collection/search/search_delegate.dart'; +import 'package:aves/widgets/collection/search_page.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/routes.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; @@ -32,7 +34,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { MediaStoreSource _mediaStore; ImageEntry _viewerEntry; - HomePageSetting _shortcutPage; + String _shortcutRouteName; List _shortcutFilters; @override @@ -83,8 +85,14 @@ class _HomePageState extends State { debugPrint('pick mimeType=$pickMimeTypes'); break; default: - final extraPage = intentData['page']; - _shortcutPage = HomePageSetting.values.firstWhere((v) => v.toString().split('.')[1] == extraPage, orElse: () => null); + // do not use 'route' as extra key, as the Flutter framework acts on it + final extraRoute = intentData['page']; + switch (extraRoute) { + case CollectionPage.routeName: + case AlbumListPage.routeName: + case SearchPage.routeName: + _shortcutRouteName = extraRoute; + } final extraFilters = intentData['filters']; _shortcutFilters = extraFilters != null ? (extraFilters as List).cast() : null; } @@ -117,12 +125,12 @@ class _HomePageState extends State { ); } - HomePageSetting startPage; + String routeName; Iterable filters; if (AvesApp.mode == AppMode.pick) { - startPage = HomePageSetting.collection; + routeName = CollectionPage.routeName; } else { - startPage = _shortcutPage ?? settings.homePage; + routeName = _shortcutRouteName ?? settings.homePage.routeName; filters = (_shortcutFilters ?? []).map((filterString) { switch (filterString) { case 'anyVideo': @@ -132,14 +140,17 @@ class _HomePageState extends State { return null; }); } - switch (startPage) { - case HomePageSetting.albums: + switch (routeName) { + case AlbumListPage.routeName: return DirectMaterialPageRoute( settings: RouteSettings(name: AlbumListPage.routeName), builder: (_) => AlbumListPage(source: _mediaStore), ); - case HomePageSetting.search: - case HomePageSetting.collection: + case SearchPage.routeName: + return SearchPageRoute( + delegate: ImageSearchDelegate(source: _mediaStore), + ); + case CollectionPage.routeName: default: return DirectMaterialPageRoute( settings: RouteSettings(name: CollectionPage.routeName),