settings live search

This commit is contained in:
Thibault Deckers 2022-05-24 15:34:24 +09:00
parent 2174497567
commit 2c917becea
11 changed files with 258 additions and 235 deletions

View file

@ -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<CollectionAppBar> with SingleTickerPr
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
searchFieldLabel: context.l10n.searchCollectionFieldHint,
source: collection.source,
parentCollection: collection,
),

View file

@ -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,
),

View file

@ -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<Widget> 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<double> 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<SearchBody?> currentBodyNotifier = ValueNotifier(null);
SearchBody? get currentBody => currentBodyNotifier.value;
set currentBody(SearchBody? value) {
currentBodyNotifier.value = value;
}
SearchPageRoute? route;
}

View file

@ -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<double> animation;
const SearchPage({
@ -31,23 +29,40 @@ class _SearchPageState extends State<SearchPage> {
@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<SearchPage> {
});
}
@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<SearchPage> {
focusNode: _focusNode,
decoration: InputDecoration(
border: InputBorder.none,
hintText: context.l10n.searchCollectionFieldHint,
hintText: widget.delegate.searchFieldLabel,
hintStyle: theme.inputDecorationTheme.hintStyle,
),
textInputAction: TextInputAction.search,

View file

@ -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<T> extends PageRoute<T> {
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<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: animation,
child: child,
);
}
@override
Animation<double> createAnimation() {
final animation = super.createAnimation();
delegate.proxyAnimation.parent = animation;
return animation;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> 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;
}
}

View file

@ -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<T extends CollectionFilter> with FeedbackMi
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
searchFieldLabel: context.l10n.searchCollectionFieldHint,
source: context.read<CollectionSource>(),
),
),

View file

@ -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<T extends CollectionFilter> extends State<FilterGri
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
searchFieldLabel: context.l10n.searchCollectionFieldHint,
source: source,
),
),

View file

@ -17,9 +17,10 @@ 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/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/search/search_page.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@ -47,7 +48,11 @@ class _HomePageState extends State<HomePage> {
String? _shortcutRouteName, _shortcutSearchQuery;
Set<String>? _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<HomePage> {
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<HomePage> {
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,

View file

@ -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<String?> _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<Widget> 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<double> 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<SearchBody?> 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<T> extends PageRoute<T> {
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<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: animation,
child: child,
);
}
@override
Animation<double> createAnimation() {
final animation = super.createAnimation();
delegate.proxyAnimation.parent = animation;
return animation;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> 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;
}
}

View file

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

View file

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