diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 81922647f..487d04f8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -225,23 +225,6 @@ class MediaStoreImageProvider : ImageProvider() { return found } - private fun hasEntry(context: Context, contentUri: Uri): Boolean { - var found = false - val projection = arrayOf(MediaStore.MediaColumns._ID) - try { - val cursor = context.contentResolver.query(contentUri, projection, null, null, null) - if (cursor != null) { - while (cursor.moveToNext()) { - found = true - } - cursor.close() - } - } catch (e: Exception) { - Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e) - } - return found - } - private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 781f48d72..f50d14725 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -520,6 +520,10 @@ } }, + "collectionActionShowTitleSearch": "Show title filter", + "@collectionActionShowTitleSearch": {}, + "collectionActionHideTitleSearch": "Hide title filter", + "@collectionActionHideTitleSearch": {}, "collectionActionAddShortcut": "Add shortcut", "@collectionActionAddShortcut": {}, "collectionActionCopy": "Copy to album", @@ -531,6 +535,9 @@ "collectionActionEdit": "Edit", "@collectionActionEdit": {}, + "collectionSearchTitlesHintText": "Search titles", + "@collectionSearchTitlesHintText": {}, + "collectionSortTitle": "Sort", "@collectionSortTitle": {}, "collectionSortDate": "By date", diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index f272b98cd..f85e69d6f 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -10,7 +10,8 @@ enum EntrySetAction { selectAll, selectNone, // browsing - search, + searchCollection, + toggleTitleSearch, addShortcut, // browsing or selecting map, @@ -38,7 +39,8 @@ class EntrySetActions { ]; static const browsing = [ - EntrySetAction.search, + EntrySetAction.searchCollection, + EntrySetAction.toggleTitleSearch, EntrySetAction.addShortcut, EntrySetAction.map, EntrySetAction.stats, @@ -71,8 +73,11 @@ extension ExtraEntrySetAction on EntrySetAction { case EntrySetAction.selectNone: return context.l10n.menuActionSelectNone; // browsing - case EntrySetAction.search: + case EntrySetAction.searchCollection: return MaterialLocalizations.of(context).searchFieldLabel; + case EntrySetAction.toggleTitleSearch: + // different data depending on toggle state + return context.l10n.collectionActionShowTitleSearch; case EntrySetAction.addShortcut: return context.l10n.collectionActionAddShortcut; // browsing or selecting @@ -122,8 +127,11 @@ extension ExtraEntrySetAction on EntrySetAction { case EntrySetAction.selectNone: return AIcons.unselected; // browsing - case EntrySetAction.search: + case EntrySetAction.searchCollection: return AIcons.search; + case EntrySetAction.toggleTitleSearch: + // different data depending on toggle state + return AIcons.filter; case EntrySetAction.addShortcut: return AIcons.addShortcut; // browsing or selecting diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 0ea2ea03f..b7d3bd414 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -12,15 +12,15 @@ class QueryFilter extends CollectionFilter { static final RegExp exactRegex = RegExp('^"(.*)"\$'); final String query; - final bool colorful; + final bool colorful, live; late final EntryFilter _test; @override - List get props => [query]; + List get props => [query, live]; - QueryFilter(this.query, {this.colorful = true}) { + QueryFilter(this.query, {this.colorful = true, this.live = false}) { var upQuery = query.toUpperCase(); - if (upQuery.startsWith('id:')) { + if (upQuery.startsWith('ID:')) { final id = int.tryParse(upQuery.substring(3)); _test = (entry) => entry.contentId == id; return; diff --git a/lib/model/query.dart b/lib/model/query.dart new file mode 100644 index 000000000..7bb5c5241 --- /dev/null +++ b/lib/model/query.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:aves/utils/change_notifier.dart'; +import 'package:flutter/foundation.dart'; + +class Query extends ChangeNotifier { + bool _enabled = false; + + bool get enabled => _enabled; + + set enabled(bool value) { + _enabled = value; + _enabledStreamController.add(_enabled); + queryNotifier.value = ''; + notifyListeners(); + + if (_enabled) { + focusRequestNotifier.notifyListeners(); + } + } + + void toggle() => enabled = !enabled; + + final StreamController _enabledStreamController = StreamController.broadcast(); + + Stream get enabledStream => _enabledStreamController.stream; + + final AChangeNotifier focusRequestNotifier = AChangeNotifier(); + + final ValueNotifier queryNotifier = ValueNotifier(''); +} diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index c6d6f07ba..96a8cc572 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -37,7 +37,7 @@ class SettingsDefaults { static const collectionSectionFactor = EntryGroupFactor.month; static const collectionSortFactor = EntrySortFactor.date; static const collectionBrowsingQuickActions = [ - EntrySetAction.search, + EntrySetAction.searchCollection, ]; static const collectionSelectionQuickActions = [ EntrySetAction.share, diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 76c6026fe..24424de3e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -7,6 +7,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/query.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; @@ -126,6 +127,14 @@ class CollectionLens with ChangeNotifier { _onFilterChanged(); } + void setLiveQuery(String query) { + filters.removeWhere((v) => v is QueryFilter && v.live); + if (query.isNotEmpty) { + filters.add(QueryFilter(query, live: true)); + } + _onFilterChanged(); + } + void _onFilterChanged() { _refresh(); filterChangeNotifier.notifyListeners(); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 7f3eb7349..421714a50 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -46,6 +46,8 @@ class AIcons { static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; + static const IconData filter = MdiIcons.filterOutline; + static const IconData filterOff = MdiIcons.filterOffOutline; static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 33533ebdc..5135feec9 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -13,6 +15,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/query_bar.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; @@ -40,20 +43,27 @@ class CollectionAppBar extends StatefulWidget { } class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { + final List _subscriptions = []; final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); late AnimationController _browseToSelectAnimation; late Future _canAddShortcutsLoader; final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + final FocusNode _queryBarFocusNode = FocusNode(); + late final Listenable _queryFocusRequestNotifier; CollectionLens get collection => widget.collection; CollectionSource get source => collection.source; - bool get hasFilters => collection.filters.isNotEmpty; + bool get showFilterBar => collection.filters.any((v) => !(v is QueryFilter && v.live)); @override void initState() { super.initState(); + final query = context.read(); + _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); + _queryFocusRequestNotifier = query.focusRequestNotifier; + _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); _browseToSelectAnimation = AnimationController( duration: context.read().iconAnimation, vsync: this, @@ -74,8 +84,12 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void dispose() { _unregisterWidget(widget); + _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); _isSelectingNotifier.removeListener(_onActivityChange); _browseToSelectAnimation.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); super.dispose(); } @@ -90,37 +104,53 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return Selector, Tuple2>( - selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), - builder: (context, s, child) { - final isSelecting = s.item1; - final selectedItemCount = s.item2; - _isSelectingNotifier.value = isSelecting; - return AnimatedBuilder( - animation: collection.filterChangeNotifier, - builder: (context, child) { - final removableFilters = appMode != AppMode.pickInternal; - return FutureBuilder( - future: _canAddShortcutsLoader, - builder: (context, snapshot) { - final canAddShortcuts = snapshot.data ?? false; - return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: _buildAppBarTitle(isSelecting), - actions: _buildActions( - isSelecting: isSelecting, - selectedItemCount: selectedItemCount, - supportShortcuts: canAddShortcuts, - ), - bottom: hasFilters - ? FilterBar( - filters: collection.filters, - removable: removableFilters, - onTap: removableFilters ? collection.removeFilter : null, - ) - : null, - titleSpacing: 0, - floating: true, + return FutureBuilder( + future: _canAddShortcutsLoader, + builder: (context, snapshot) { + final canAddShortcuts = snapshot.data ?? false; + return Selector, Tuple2>( + selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), + builder: (context, s, child) { + final isSelecting = s.item1; + final selectedItemCount = s.item2; + _isSelectingNotifier.value = isSelecting; + return AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final removableFilters = appMode != AppMode.pickInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: _buildAppBarTitle(isSelecting), + actions: _buildActions( + isSelecting: isSelecting, + selectedItemCount: selectedItemCount, + supportShortcuts: canAddShortcuts, + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(appBarBottomHeight), + child: Column( + children: [ + if (showFilterBar) + FilterBar( + filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ) + ], + ), + ), + titleSpacing: 0, + floating: true, + ); + }, ); }, ); @@ -130,6 +160,11 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } + double get appBarBottomHeight { + final hasQuery = context.read().enabled; + return (showFilterBar ? FilterBar.preferredHeight : .0) + (hasQuery ? EntryQueryBar.preferredHeight : .0); + } + Widget _buildAppBarLeading(bool isSelecting) { VoidCallback? onPressed; String? tooltip; @@ -263,20 +298,46 @@ class _CollectionAppBarState extends State with SingleTickerPr Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}'); Widget _toActionButton(EntrySetAction action, {required bool enabled}) { - return IconButton( - key: _getActionKey(action), - icon: action.getIcon(), - onPressed: enabled ? () => _onActionSelected(action) : null, - tooltip: action.getText(context), - ); + final onPressed = enabled ? () => _onActionSelected(action) : null; + switch (action) { + case EntrySetAction.toggleTitleSearch: + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return _TitleSearchToggler( + queryEnabled: queryEnabled, + onPressed: onPressed, + ); + }, + ); + default: + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: onPressed, + tooltip: action.getText(context), + ); + } } PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled}) { + late Widget child; + switch (action) { + case EntrySetAction.toggleTitleSearch: + child = _TitleSearchToggler( + queryEnabled: context.read().enabled, + isMenuItem: true, + ); + break; + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + break; + } return PopupMenuItem( key: _getActionKey(action), value: action, enabled: enabled, - child: MenuRow(text: action.getText(context), icon: action.getIcon()), + child: child, ); } @@ -327,10 +388,10 @@ class _CollectionAppBarState extends State with SingleTickerPr } void _onFilterChanged() { - widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0); + _updateAppBarHeight(); - if (hasFilters) { - final filters = collection.filters; + final filters = collection.filters; + if (filters.isNotEmpty) { final selection = context.read>(); if (selection.isSelecting) { final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); @@ -339,6 +400,10 @@ class _CollectionAppBarState extends State with SingleTickerPr } } + void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); + + void _updateAppBarHeight() => widget.appBarHeightNotifier.value = kToolbarHeight + appBarBottomHeight; + Future _onActionSelected(EntrySetAction action) async { switch (action) { // general @@ -358,7 +423,8 @@ class _CollectionAppBarState extends State with SingleTickerPr context.read>().clearSelection(); break; // browsing - case EntrySetAction.search: + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: case EntrySetAction.addShortcut: // browsing or selecting case EntrySetAction.map: @@ -435,3 +501,30 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } } + +class _TitleSearchToggler extends StatelessWidget { + final bool queryEnabled, isMenuItem; + final VoidCallback? onPressed; + + const _TitleSearchToggler({ + required this.queryEnabled, + this.isMenuItem = false, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final icon = Icon(queryEnabled ? AIcons.filterOff : AIcons.filter); + final text = queryEnabled ? context.l10n.collectionActionHideTitleSearch : context.l10n.collectionActionShowTitleSearch; + return isMenuItem + ? MenuRow( + text: text, + icon: icon, + ) + : IconButton( + icon: icon, + onPressed: onPressed, + tooltip: text, + ); + } +} diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 9cd50c7f6..20d8607e2 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -5,6 +5,7 @@ import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.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/drawer/app_drawer.dart'; import 'package:flutter/foundation.dart'; @@ -39,25 +40,27 @@ class _CollectionPageState extends State { return MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( - 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( - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), + child: QueryProvider( + 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( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + ), ), ), ), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 4597268a8..4f9acfb95 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -8,6 +8,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -60,8 +61,10 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.selectNone: return isSelecting && selectedItemCount == itemCount; // browsing - case EntrySetAction.search: + case EntrySetAction.searchCollection: return appMode.canSearch && !isSelecting; + case EntrySetAction.toggleTitleSearch: + return !isSelecting; case EntrySetAction.addShortcut: return appMode == AppMode.main && !isSelecting && supportShortcuts; // browsing or selecting @@ -102,7 +105,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa return selectedItemCount < itemCount; case EntrySetAction.selectNone: return hasSelection; - case EntrySetAction.search: + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: case EntrySetAction.addShortcut: return true; case EntrySetAction.map: @@ -133,9 +137,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.selectNone: break; // browsing - case EntrySetAction.search: + case EntrySetAction.searchCollection: _goToSearch(context); break; + case EntrySetAction.toggleTitleSearch: + context.read().toggle(); + break; case EntrySetAction.addShortcut: _addShortcut(context); break; diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 0e5971f61..d73656a2f 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -3,7 +3,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; -class FilterBar extends StatefulWidget implements PreferredSizeWidget { +class FilterBar extends StatefulWidget { static const double verticalPadding = 16; static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; @@ -19,9 +19,6 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { }) : filters = List.from(filters)..sort(), super(key: key); - @override - final Size preferredSize = const Size.fromHeight(preferredHeight); - @override _FilterBarState createState() => _FilterBarState(); } diff --git a/lib/widgets/collection/query_bar.dart b/lib/widgets/collection/query_bar.dart new file mode 100644 index 000000000..f915b9a49 --- /dev/null +++ b/lib/widgets/collection/query_bar.dart @@ -0,0 +1,76 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EntryQueryBar extends StatefulWidget { + final ValueNotifier queryNotifier; + final FocusNode focusNode; + + static const preferredHeight = kToolbarHeight; + + const EntryQueryBar({ + Key? key, + required this.queryNotifier, + required this.focusNode, + }) : super(key: key); + + @override + _EntryQueryBarState createState() => _EntryQueryBarState(); +} + +class _EntryQueryBarState extends State { + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant EntryQueryBar oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + // TODO TLAD focus on text field when enabled (`autofocus` is unusable) + // TODO TLAD lose focus on navigation to viewer? + void _registerWidget(EntryQueryBar widget) { + widget.queryNotifier.addListener(_onQueryChanged); + } + + void _unregisterWidget(EntryQueryBar widget) { + widget.queryNotifier.removeListener(_onQueryChanged); + } + + @override + Widget build(BuildContext context) { + return Container( + height: EntryQueryBar.preferredHeight, + alignment: Alignment.topCenter, + child: Selector, bool>( + selector: (context, selection) => !selection.isSelecting, + builder: (context, editable, child) => QueryBar( + queryNotifier: widget.queryNotifier, + focusNode: widget.focusNode, + hintText: context.l10n.collectionSearchTitlesHintText, + editable: editable, + ), + ), + ); + } + + void _onQueryChanged() { + final query = widget.queryNotifier.value; + context.read().setLiveQuery(query); + } +} diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index 16dbb6537..e3892c578 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -244,7 +244,8 @@ class _DraggableScrollbarState extends State with TickerProv // when the user is not dragging the thumb if (!_isDragInProcess) { if (notification is ScrollUpdateNotification) { - _thumbOffsetNotifier.value = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent).clamp(thumbMinScrollExtent, thumbMaxScrollExtent); + final scrollExtent = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent); + _thumbOffsetNotifier.value = thumbMaxScrollExtent > thumbMinScrollExtent ? scrollExtent.clamp(thumbMinScrollExtent, thumbMaxScrollExtent) : thumbMinScrollExtent; } if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { diff --git a/lib/widgets/common/basic/query_bar.dart b/lib/widgets/common/basic/query_bar.dart index 3100c1a5c..2542ab544 100644 --- a/lib/widgets/common/basic/query_bar.dart +++ b/lib/widgets/common/basic/query_bar.dart @@ -7,11 +7,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class QueryBar extends StatefulWidget { - final ValueNotifier filterNotifier; + final ValueNotifier queryNotifier; + final FocusNode? focusNode; + final IconData? icon; + final String? hintText; + final bool editable; const QueryBar({ Key? key, - required this.filterNotifier, + required this.queryNotifier, + this.focusNode, + this.icon, + this.hintText, + this.editable = true, }) : super(key: key); @override @@ -22,22 +30,24 @@ class _QueryBarState extends State { final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); late TextEditingController _controller; - ValueNotifier get filterNotifier => widget.filterNotifier; + ValueNotifier get queryNotifier => widget.queryNotifier; @override void initState() { super.initState(); - _controller = TextEditingController(text: filterNotifier.value); + _controller = TextEditingController(text: queryNotifier.value); } @override Widget build(BuildContext context) { final clearButton = IconButton( icon: const Icon(AIcons.clear), - onPressed: () { - _controller.clear(); - filterNotifier.value = ''; - }, + onPressed: widget.editable + ? () { + _controller.clear(); + queryNotifier.value = ''; + } + : null, tooltip: context.l10n.clearTooltip, ); @@ -47,16 +57,18 @@ class _QueryBarState extends State { Expanded( child: TextField( controller: _controller, + focusNode: widget.focusNode ?? FocusNode(), decoration: InputDecoration( - icon: const Padding( - padding: EdgeInsetsDirectional.only(start: 16), - child: Icon(AIcons.search), + icon: Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: Icon(widget.icon ?? AIcons.filter), ), - hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintText: widget.hintText ?? MaterialLocalizations.of(context).searchFieldLabel, hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, ), textInputAction: TextInputAction.search, - onChanged: (s) => _debouncer(() => filterNotifier.value = s), + onChanged: (s) => _debouncer(() => queryNotifier.value = s.trim()), + enabled: widget.editable, ), ), ConstrainedBox( @@ -73,7 +85,7 @@ class _QueryBarState extends State { child: child, ), ), - child: value.text.isNotEmpty ? clearButton : const SizedBox.shrink(), + child: value.text.isNotEmpty ? clearButton : const SizedBox(), ), ), ) diff --git a/lib/widgets/common/providers/query_provider.dart b/lib/widgets/common/providers/query_provider.dart new file mode 100644 index 000000000..75a062eec --- /dev/null +++ b/lib/widgets/common/providers/query_provider.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/query.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class QueryProvider extends StatelessWidget { + final Widget child; + + const QueryProvider({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => Query(), + child: child, + ); + } +} diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 71d80804f..1a4634d94 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -18,10 +18,8 @@ import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -96,7 +94,7 @@ class AlbumPickAppBar extends StatelessWidget { final AlbumChipSetActionDelegate actionDelegate; final ValueNotifier queryNotifier; - static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight; + static const preferredHeight = kToolbarHeight + AlbumQueryBar.preferredHeight; const AlbumPickAppBar({ Key? key, @@ -127,8 +125,8 @@ class AlbumPickAppBar extends StatelessWidget { title: Text(title()), source: source, ), - bottom: AlbumFilterBar( - filterNotifier: queryNotifier, + bottom: AlbumQueryBar( + queryNotifier: queryNotifier, ), actions: [ if (moveType != null) @@ -176,14 +174,14 @@ class AlbumPickAppBar extends StatelessWidget { } } -class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { - final ValueNotifier filterNotifier; +class AlbumQueryBar extends StatelessWidget implements PreferredSizeWidget { + final ValueNotifier queryNotifier; static const preferredHeight = kToolbarHeight; - const AlbumFilterBar({ + const AlbumQueryBar({ Key? key, - required this.filterNotifier, + required this.queryNotifier, }) : super(key: key); @override @@ -192,10 +190,10 @@ class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return Container( - height: AlbumFilterBar.preferredHeight, + height: AlbumQueryBar.preferredHeight, alignment: Alignment.topCenter, child: QueryBar( - filterNotifier: filterNotifier, + queryNotifier: queryNotifier, ), ); } diff --git a/untranslated.json b/untranslated.json index 2bcf62d1a..fe6c80419 100644 --- a/untranslated.json +++ b/untranslated.json @@ -3,6 +3,9 @@ "unsupportedTypeDialogTitle", "unsupportedTypeDialogMessage", "editEntryDateDialogExtractFromTitle", + "collectionActionShowTitleSearch", + "collectionActionHideTitleSearch", + "collectionSearchTitlesHintText", "settingsCollectionQuickActionTabBrowsing", "settingsCollectionQuickActionTabSelecting", "settingsCollectionBrowsingQuickActionEditorBanner", @@ -17,7 +20,10 @@ "aboutLinkPolicy", "aboutCreditsTranslators", "policyPageTitle", + "collectionActionShowTitleSearch", + "collectionActionHideTitleSearch", "collectionActionEdit", + "collectionSearchTitlesHintText", "collectionEditFailureFeedback", "collectionEditSuccessFeedback", "settingsCollectionQuickActionTabBrowsing",