diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index ea768701e..7aea5e78c 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -25,6 +25,7 @@ import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/favourite_toggler.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -543,6 +544,7 @@ class _CollectionAppBarState extends State with SingleTickerPr context, SearchPageRoute( delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, source: collection.source, parentCollection: collection, ), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 9467b3354..b839a0843 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -27,6 +27,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +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'; @@ -566,6 +567,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware context, SearchPageRoute( delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, source: collection.source, parentCollection: collection, ), diff --git a/lib/widgets/common/search/delegate.dart b/lib/widgets/common/search/delegate.dart new file mode 100644 index 000000000..066efcb3b --- /dev/null +++ b/lib/widgets/common/search/delegate.dart @@ -0,0 +1,105 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +abstract class AvesSearchDelegate extends SearchDelegate { + final String routeName; + final bool canPop; + + AvesSearchDelegate({ + required this.routeName, + this.canPop = true, + String? initialQuery, + required super.searchFieldLabel, + }) { + query = initialQuery ?? ''; + } + + @override + Widget buildLeading(BuildContext context) { + // use a property instead of checking `Navigator.canPop(context)` + // because the navigator state changes as soon as we press back + // so the leading may mistakenly switch to the close button + return canPop + ? IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: transitionAnimation, + ), + onPressed: () => goBack(context), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ) + : const CloseButton( + onPressed: SystemNavigator.pop, + ); + } + + @override + List buildActions(BuildContext context) { + return [ + if (query.isNotEmpty) + IconButton( + icon: const Icon(AIcons.clear), + onPressed: () { + query = ''; + showSuggestions(context); + }, + tooltip: context.l10n.clearTooltip, + ), + ]; + } + + void goBack(BuildContext context) { + clean(); + Navigator.pop(context); + } + + void clean() { + currentBody = null; + focusNode?.unfocus(); + } + + // adapted from Flutter `SearchDelegate` in `/material/search.dart` + + @override + void showResults(BuildContext context) { + focusNode?.unfocus(); + currentBody = SearchBody.results; + } + + @override + void showSuggestions(BuildContext context) { + assert(focusNode != null, '_focusNode must be set by route before showSuggestions is called.'); + focusNode!.requestFocus(); + currentBody = SearchBody.suggestions; + } + + @override + Animation get transitionAnimation => proxyAnimation; + + FocusNode? focusNode; + + final TextEditingController queryTextController = TextEditingController(); + + final ProxyAnimation proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation); + + @override + String get query => queryTextController.text; + + @override + set query(String value) { + queryTextController.text = value; + } + + final ValueNotifier currentBodyNotifier = ValueNotifier(null); + + SearchBody? get currentBody => currentBodyNotifier.value; + + set currentBody(SearchBody? value) { + currentBodyNotifier.value = value; + } + + SearchPageRoute? route; +} diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/common/search/page.dart similarity index 85% rename from lib/widgets/search/search_page.dart rename to lib/widgets/common/search/page.dart index cf4056862..0ebe1c5e2 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/common/search/page.dart @@ -2,16 +2,14 @@ import 'dart:ui'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; -import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:aves/widgets/common/search/delegate.dart'; +import 'package:aves/widgets/common/search/route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; class SearchPage extends StatefulWidget { - static const routeName = '/search'; - - final CollectionSearchDelegate delegate; + final AvesSearchDelegate delegate; final Animation animation; const SearchPage({ @@ -31,23 +29,40 @@ class _SearchPageState extends State { @override void initState() { super.initState(); - widget.delegate.queryTextController.addListener(_onQueryChanged); + _registerWidget(widget); widget.animation.addStatusListener(_onAnimationStatusChanged); - widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); _focusNode.addListener(_onFocusChanged); - widget.delegate.focusNode = _focusNode; + } + + @override + void didUpdateWidget(covariant SearchPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.delegate != oldWidget.delegate) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } } @override void dispose() { - widget.delegate.queryTextController.removeListener(_onQueryChanged); + _unregisterWidget(widget); widget.animation.removeStatusListener(_onAnimationStatusChanged); - widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); - widget.delegate.focusNode = null; _focusNode.dispose(); super.dispose(); } + void _registerWidget(SearchPage widget) { + widget.delegate.queryTextController.addListener(_onQueryChanged); + widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); + widget.delegate.focusNode = _focusNode; + } + + void _unregisterWidget(SearchPage widget) { + widget.delegate.queryTextController.removeListener(_onQueryChanged); + widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); + widget.delegate.focusNode = null; + } + void _onAnimationStatusChanged(AnimationStatus status) { if (status != AnimationStatus.completed) { return; @@ -59,19 +74,6 @@ class _SearchPageState extends State { }); } - @override - void didUpdateWidget(covariant 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); @@ -130,7 +132,7 @@ class _SearchPageState extends State { focusNode: _focusNode, decoration: InputDecoration( border: InputBorder.none, - hintText: context.l10n.searchCollectionFieldHint, + hintText: widget.delegate.searchFieldLabel, hintStyle: theme.inputDecorationTheme.hintStyle, ), textInputAction: TextInputAction.search, diff --git a/lib/widgets/common/search/route.dart b/lib/widgets/common/search/route.dart new file mode 100644 index 000000000..c2904d8ba --- /dev/null +++ b/lib/widgets/common/search/route.dart @@ -0,0 +1,75 @@ +import 'package:aves/widgets/common/search/delegate.dart'; +import 'package:aves/widgets/common/search/page.dart'; +import 'package:flutter/material.dart'; + +// adapted from Flutter `_SearchBody` in `/material/search.dart` +enum SearchBody { suggestions, results } + +// adapted from Flutter `_SearchPageRoute` in `/material/search.dart` +class SearchPageRoute extends PageRoute { + SearchPageRoute({ + required this.delegate, + }) : super(settings: RouteSettings(name: delegate.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 AvesSearchDelegate 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/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index dbfd3fd88..f6c0b626b 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -15,6 +15,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/cover_selection_dialog.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; @@ -246,6 +247,7 @@ abstract class ChipSetActionDelegate with FeedbackMi context, SearchPageRoute( delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, source: context.read(), ), ), diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 549770ea5..108a0cfd9 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; @@ -222,6 +223,7 @@ class _FilterGridAppBarState extends State { String? _shortcutRouteName, _shortcutSearchQuery; Set? _shortcutFilters; - static const allowedShortcutRoutes = [CollectionPage.routeName, AlbumListPage.routeName, SearchPage.routeName]; + static const allowedShortcutRoutes = [ + CollectionPage.routeName, + AlbumListPage.routeName, + CollectionSearchDelegate.pageRouteName, + ]; @override void initState() { @@ -103,7 +108,7 @@ class _HomePageState extends State { appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; break; case 'search': - _shortcutRouteName = SearchPage.routeName; + _shortcutRouteName = CollectionSearchDelegate.pageRouteName; _shortcutSearchQuery = intentData['query']; break; default: @@ -245,9 +250,10 @@ class _HomePageState extends State { settings: const RouteSettings(name: AlbumListPage.routeName), builder: (_) => const AlbumListPage(), ); - case SearchPage.routeName: + case CollectionSearchDelegate.pageRouteName: return SearchPageRoute( delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, source: source, canPop: false, initialQuery: _shortcutSearchQuery, diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 0c09c5893..66ada398b 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -14,23 +14,21 @@ 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/ref/mime_types.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.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/search/search_page.dart'; +import 'package:aves/widgets/common/search/delegate.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -class CollectionSearchDelegate { +class CollectionSearchDelegate extends AvesSearchDelegate { final CollectionSource source; final CollectionLens? parentCollection; final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); - final bool canPop; + static const pageRouteName = '/search'; static const int searchHistoryCount = 10; static final typeFilters = [ FavouriteFilter.instance, @@ -46,46 +44,18 @@ class CollectionSearchDelegate { ]; CollectionSearchDelegate({ + required super.searchFieldLabel, required this.source, this.parentCollection, - this.canPop = true, + super.canPop, String? initialQuery, - }) { + }) : super( + routeName: pageRouteName, + ) { query = initialQuery ?? ''; } - Widget buildLeading(BuildContext context) { - // use a property instead of checking `Navigator.canPop(context)` - // because the navigator state changes as soon as we press back - // so the leading may mistakenly switch to the close button - return canPop - ? IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, - progress: transitionAnimation, - ), - onPressed: () => _goBack(context), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ) - : const CloseButton( - onPressed: SystemNavigator.pop, - ); - } - - List buildActions(BuildContext context) { - return [ - if (query.isNotEmpty) - IconButton( - icon: const Icon(AIcons.clear), - onPressed: () { - query = ''; - showSuggestions(context); - }, - tooltip: context.l10n.clearTooltip, - ), - ]; - } - + @override Widget buildSuggestions(BuildContext context) { final upQuery = query.trim().toUpperCase(); bool containQuery(String s) => s.toUpperCase().contains(upQuery); @@ -208,6 +178,7 @@ class CollectionSearchDelegate { ); } + @override Widget buildResults(BuildContext context) { WidgetsBinding.instance.addPostFrameCallback((_) { // `buildResults` is called in the build phase, @@ -215,7 +186,7 @@ class CollectionSearchDelegate { // and possibly trigger a rebuild here _select(context, _buildQueryFilter(true)); }); - return const SizedBox.shrink(); + return const SizedBox(); } QueryFilter? _buildQueryFilter(bool colorful) { @@ -225,7 +196,7 @@ class CollectionSearchDelegate { void _select(BuildContext context, CollectionFilter? filter) { if (filter == null) { - _goBack(context); + goBack(context); return; } @@ -248,17 +219,12 @@ class CollectionSearchDelegate { // so that hero animation target is ready in the `FilterBar`, // even when the target is a child of an `AnimatedList` WidgetsBinding.instance.addPostFrameCallback((_) { - _goBack(context); + goBack(context); }); } - void _goBack(BuildContext context) { - _clean(); - Navigator.pop(context); - } - void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { - _clean(); + clean(); Navigator.pushAndRemoveUntil( context, MaterialPageRoute( @@ -271,118 +237,4 @@ class CollectionSearchDelegate { (route) => false, ); } - - void _clean() { - currentBody = null; - focusNode?.unfocus(); - } - - // adapted from Flutter `SearchDelegate` in `/material/search.dart` - - 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) { - queryTextController.text = value; - } - - final ValueNotifier currentBodyNotifier = ValueNotifier(null); - - SearchBody? get currentBody => currentBodyNotifier.value; - - set currentBody(SearchBody? value) { - currentBodyNotifier.value = value; - } - - SearchPageRoute? route; -} - -// adapted from Flutter `_SearchBody` in `/material/search.dart` -enum SearchBody { suggestions, results } - -// adapted from Flutter `_SearchPageRoute` in `/material/search.dart` -class SearchPageRoute extends PageRoute { - SearchPageRoute({ - required this.delegate, - }) : super(settings: const 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 CollectionSearchDelegate 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/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index a8e20d6e5..37cc46737 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -15,6 +15,7 @@ import 'package:aves/widgets/common/basic/menu.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/common/search/route.dart'; import 'package:aves/widgets/settings/accessibility/accessibility.dart'; import 'package:aves/widgets/settings/app_export/items.dart'; import 'package:aves/widgets/settings/app_export/selection_dialog.dart'; @@ -221,11 +222,13 @@ class _SettingsPageState extends State with FeedbackMixin { } void _goToSearch(BuildContext context) { - showSearch( - context: context, - delegate: SettingsSearchDelegate( - searchFieldLabel: context.l10n.settingsSearchFieldLabel, - sections: sections, + Navigator.push( + context, + SearchPageRoute( + delegate: SettingsSearchDelegate( + searchFieldLabel: context.l10n.settingsSearchFieldLabel, + sections: sections, + ), ), ); } diff --git a/lib/widgets/settings/settings_search.dart b/lib/widgets/settings/settings_search.dart index 15df2e683..7c92ea90a 100644 --- a/lib/widgets/settings/settings_search.dart +++ b/lib/widgets/settings/settings_search.dart @@ -3,58 +3,27 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -class SettingsSearchDelegate extends SearchDelegate { +class SettingsSearchDelegate extends AvesSearchDelegate { final List sections; + static const pageRouteName = '/settings/search'; + SettingsSearchDelegate({ - required String searchFieldLabel, + required super.searchFieldLabel, required this.sections, }) : super( - searchFieldLabel: searchFieldLabel, + routeName: pageRouteName, ); @override - Widget buildLeading(BuildContext context) { - return IconButton( - icon: AnimatedIcon( - icon: AnimatedIcons.menu_arrow, - progress: transitionAnimation, - ), - onPressed: () => Navigator.pop(context), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ); - } - - @override - List buildActions(BuildContext context) { - return [ - if (query.isNotEmpty) - IconButton( - icon: const Icon(AIcons.clear), - onPressed: () { - query = ''; - showSuggestions(context); - }, - tooltip: context.l10n.clearTooltip, - ), - ]; - } - - @override - Widget buildSuggestions(BuildContext context) => const SizedBox(); - - @override - Widget buildResults(BuildContext context) { - if (query.isEmpty) { - showSuggestions(context); - return const SizedBox(); - } - + Widget buildSuggestions(BuildContext context) { final upQuery = query.toUpperCase().trim(); + if (upQuery.isEmpty) return const SizedBox(); bool testKey(String key) => key.toUpperCase().contains(upQuery); @@ -109,4 +78,7 @@ class SettingsSearchDelegate extends SearchDelegate { ), ); } + + @override + Widget buildResults(BuildContext context) => buildSuggestions(context); }