#277 albums/countries/tags: show/hide title filter

This commit is contained in:
Thibault Deckers 2022-08-04 12:34:29 +02:00
parent daedd552e2
commit 5dc6b22fb6
23 changed files with 471 additions and 354 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Viewer: optional gesture to show previous/next item - Viewer: optional gesture to show previous/next item
- Albums / Countries / Tags: live title filter
## <a id="v1.6.11"></a>[v1.6.11] - 2022-07-26 ## <a id="v1.6.11"></a>[v1.6.11] - 2022-07-26

View file

@ -10,6 +10,7 @@ enum ChipSetAction {
selectNone, selectNone,
// browsing // browsing
search, search,
toggleTitleSearch,
createAlbum, createAlbum,
// browsing or selecting // browsing or selecting
map, map,
@ -35,6 +36,7 @@ class ChipSetActions {
static const browsing = [ static const browsing = [
ChipSetAction.search, ChipSetAction.search,
ChipSetAction.toggleTitleSearch,
ChipSetAction.createAlbum, ChipSetAction.createAlbum,
ChipSetAction.map, ChipSetAction.map,
ChipSetAction.slideshow, ChipSetAction.slideshow,
@ -69,6 +71,9 @@ extension ExtraChipSetAction on ChipSetAction {
// browsing // browsing
case ChipSetAction.search: case ChipSetAction.search:
return MaterialLocalizations.of(context).searchFieldLabel; return MaterialLocalizations.of(context).searchFieldLabel;
case ChipSetAction.toggleTitleSearch:
// different data depending on toggle state
return context.l10n.collectionActionShowTitleSearch;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
return context.l10n.chipActionCreateAlbum; return context.l10n.chipActionCreateAlbum;
// browsing or selecting // browsing or selecting
@ -111,6 +116,9 @@ extension ExtraChipSetAction on ChipSetAction {
// browsing // browsing
case ChipSetAction.search: case ChipSetAction.search:
return AIcons.search; return AIcons.search;
case ChipSetAction.toggleTitleSearch:
// different data depending on toggle state
return AIcons.filter;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
return AIcons.add; return AIcons.add;
// browsing or selecting // browsing or selecting

View file

@ -4,6 +4,10 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class Query extends ChangeNotifier { class Query extends ChangeNotifier {
final AChangeNotifier _focusRequestNotifier = AChangeNotifier();
final ValueNotifier<String> _queryNotifier = ValueNotifier('');
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
Query({required String? initialValue}) { Query({required String? initialValue}) {
if (initialValue != null && initialValue.isNotEmpty) { if (initialValue != null && initialValue.isNotEmpty) {
_enabled = true; _enabled = true;
@ -28,11 +32,9 @@ class Query extends ChangeNotifier {
void toggle() => enabled = !enabled; void toggle() => enabled = !enabled;
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
Stream<bool> get enabledStream => _enabledStreamController.stream; Stream<bool> get enabledStream => _enabledStreamController.stream;
final AChangeNotifier focusRequestNotifier = AChangeNotifier(); AChangeNotifier get focusRequestNotifier => _focusRequestNotifier;
final ValueNotifier<String> queryNotifier = ValueNotifier(''); ValueNotifier<String> get queryNotifier => _queryNotifier;
} }

View file

@ -19,11 +19,12 @@ import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/collection/query_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/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/app_bar/favourite_toggler.dart';
import 'package:aves/widgets/common/app_bar/title_search_toggler.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/identity/aves_app_bar.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
@ -142,7 +143,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
isSelecting: isSelecting, isSelecting: isSelecting,
), ),
title: _buildAppBarTitle(isSelecting), title: _buildAppBarTitle(isSelecting),
actions: _buildActions(selection), actions: _buildActions(context, selection),
bottom: Column( bottom: Column(
children: [ children: [
if (showFilterBar) if (showFilterBar)
@ -234,7 +235,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
} }
List<Widget> _buildActions(Selection<AvesEntry> selection) { List<Widget> _buildActions(BuildContext context, Selection<AvesEntry> selection) {
final isSelecting = selection.isSelecting; final isSelecting = selection.isSelecting;
final selectedItemCount = selection.selectedItems.length; final selectedItemCount = selection.selectedItems.length;
@ -328,7 +329,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return Selector<Query?, bool>( return Selector<Query?, bool>(
selector: (context, query) => query?.enabled ?? false, selector: (context, query) => query?.enabled ?? false,
builder: (context, queryEnabled, child) { builder: (context, queryEnabled, child) {
return _TitleSearchToggler( return TitleSearchToggler(
queryEnabled: queryEnabled, queryEnabled: queryEnabled,
onPressed: onPressed, onPressed: onPressed,
); );
@ -353,7 +354,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
late Widget child; late Widget child;
switch (action) { switch (action) {
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
child = _TitleSearchToggler( child = TitleSearchToggler(
queryEnabled: context.read<Query>().enabled, queryEnabled: context.read<Query>().enabled,
isMenuItem: true, isMenuItem: true,
); );
@ -556,30 +557,3 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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,
);
}
}

View file

@ -186,52 +186,51 @@ class _CollectionSectionedContent extends StatefulWidget {
} }
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
TileLayout get tileLayout => widget.tileLayout; TileLayout get tileLayout => widget.tileLayout;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scrollView = AnimationLimiter( final scrollView = AnimationLimiter(
child: _CollectionScrollView( child: _CollectionScrollView(
scrollableKey: scrollableKey, scrollableKey: _scrollableKey,
collection: collection, collection: collection,
appBar: CollectionAppBar( appBar: CollectionAppBar(
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
collection: collection, collection: collection,
), ),
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: widget.isScrollingNotifier, isScrollingNotifier: widget.isScrollingNotifier,
scrollController: scrollController, scrollController: scrollController,
), ),
); );
final scaler = _CollectionScaler( final scaler = _CollectionScaler(
scrollableKey: scrollableKey, scrollableKey: _scrollableKey,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
tileLayout: tileLayout, tileLayout: tileLayout,
child: scrollView, child: scrollView,
); );
final selector = GridSelectionGestureDetector( final selector = GridSelectionGestureDetector(
scrollableKey: scrollableKey, scrollableKey: _scrollableKey,
selectable: context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia), selectable: context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia),
items: collection.sortedEntries, items: collection.sortedEntries,
scrollController: scrollController, scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
child: scaler, child: scaler,
); );
return GridItemTracker<AvesEntry>( return GridItemTracker<AvesEntry>(
scrollableKey: scrollableKey, scrollableKey: _scrollableKey,
tileLayout: tileLayout, tileLayout: tileLayout,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: _appBarHeightNotifier,
scrollController: scrollController, scrollController: scrollController,
child: selector, child: selector,
); );

View file

@ -128,7 +128,7 @@ class _CollectionPageState extends State<CollectionPage> {
), ),
), ),
floatingActionButton: _buildFab(context, hasSelection), floatingActionButton: _buildFab(context, hasSelection),
drawer: AppDrawer(currentCollection: _collection), drawer: canNavigate ? AppDrawer(currentCollection: _collection) : null,
bottomNavigationBar: showBottomNavigationBar bottomNavigationBar: showBottomNavigationBar
? AppBottomNavBar( ? AppBottomNavBar(
events: _draggableScrollBarEventStreamController.stream, events: _draggableScrollBarEventStreamController.stream,

View file

@ -0,0 +1,32 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
class TitleSearchToggler extends StatelessWidget {
final bool queryEnabled, isMenuItem;
final VoidCallback? onPressed;
const TitleSearchToggler({
super.key,
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,
);
}
}

View file

@ -3,23 +3,22 @@ import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.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/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.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/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/app_bar.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -61,10 +60,25 @@ class _AlbumPickPage extends StatefulWidget {
} }
class _AlbumPickPageState extends State<_AlbumPickPage> { class _AlbumPickPageState extends State<_AlbumPickPage> {
final _queryNotifier = ValueNotifier(''); final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
CollectionSource get source => widget.source; CollectionSource get source => widget.source;
String get title {
switch (widget.moveType) {
case MoveType.copy:
return context.l10n.albumPickPageTitleCopy;
case MoveType.move:
return context.l10n.albumPickPageTitleMove;
case MoveType.export:
return context.l10n.albumPickPageTitleExport;
case MoveType.toBin:
case MoveType.fromBin:
case null:
return context.l10n.albumPickPageTitlePick;
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableProvider<ValueNotifier<AppMode>>.value( return ListenableProvider<ValueNotifier<AppMode>>.value(
@ -79,24 +93,21 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
return SelectionProvider<FilterGridItem<AlbumFilter>>( return SelectionProvider<FilterGridItem<AlbumFilter>>(
child: FilterGridPage<AlbumFilter>( child: FilterGridPage<AlbumFilter>(
settingsRouteKey: AlbumListPage.routeName, settingsRouteKey: AlbumListPage.routeName,
appBar: _AlbumPickAppBar( appBar: FilterGridAppBar(
source: source, source: source,
moveType: widget.moveType, title: title,
actionDelegate: AlbumChipSetActionDelegate(gridItems), actionDelegate: AlbumChipSetActionDelegate(gridItems),
queryNotifier: _queryNotifier, actionsBuilder: _buildActions,
isEmpty: false,
appBarHeightNotifier: _appBarHeightNotifier,
), ),
appBarHeight: AvesAppBar.appBarHeightForContentHeight(_AlbumPickAppBar.contentHeight), appBarHeightNotifier: _appBarHeightNotifier,
sections: AlbumListPage.groupToSections(context, source, gridItems), sections: AlbumListPage.groupToSections(context, source, gridItems),
newFilters: source.getNewAlbumFilters(context), newFilters: source.getNewAlbumFilters(context),
sortFactor: settings.albumSortFactor, sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
selectable: false, selectable: false,
queryNotifier: _queryNotifier, applyQuery: AlbumListPage.applyQuery,
applyQuery: (filters, query) {
if (query.isEmpty) return filters;
query = query.toUpperCase();
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
},
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: context.l10n.albumEmpty, text: context.l10n.albumEmpty,
@ -110,57 +121,15 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
), ),
); );
} }
}
class _AlbumPickAppBar extends StatelessWidget { List<Widget> _buildActions(
final CollectionSource source; BuildContext context,
final MoveType? moveType; AppMode appMode,
final AlbumChipSetActionDelegate actionDelegate; Selection<FilterGridItem<AlbumFilter>> selection,
final ValueNotifier<String> queryNotifier; AlbumChipSetActionDelegate actionDelegate,
) {
static const contentHeight = kToolbarHeight + _AlbumQueryBar.preferredHeight;
const _AlbumPickAppBar({
required this.source,
required this.moveType,
required this.actionDelegate,
required this.queryNotifier,
});
@override
Widget build(BuildContext context) {
String title() {
switch (moveType) {
case MoveType.copy:
return context.l10n.albumPickPageTitleCopy;
case MoveType.move:
return context.l10n.albumPickPageTitleMove;
case MoveType.export:
return context.l10n.albumPickPageTitleExport;
case MoveType.toBin:
case MoveType.fromBin:
case null:
return context.l10n.albumPickPageTitlePick;
}
}
return AvesAppBar(
contentHeight: contentHeight,
leading: const BackButton(),
title: SourceStateAwareAppBarTitle(
title: Text(title()),
source: source,
),
actions: _buildActions(context),
bottom: _AlbumQueryBar(
queryNotifier: queryNotifier,
),
);
}
List<StatelessWidget> _buildActions(BuildContext context) {
return [ return [
if (moveType != null) if (widget.moveType != null)
IconButton( IconButton(
icon: const Icon(AIcons.add), icon: const Icon(AIcons.add),
onPressed: () async { onPressed: () async {
@ -180,10 +149,9 @@ class _AlbumPickAppBar extends StatelessWidget {
child: PopupMenuButton<ChipSetAction>( child: PopupMenuButton<ChipSetAction>(
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
PopupMenuItem( FilterGridAppBar.toMenuItem(context, ChipSetAction.configureView, enabled: true),
value: ChipSetAction.configureView, const PopupMenuDivider(),
child: MenuRow(text: context.l10n.menuActionConfigureView, icon: const Icon(AIcons.view)), FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true),
),
]; ];
}, },
onSelected: (action) async { onSelected: (action) async {
@ -200,27 +168,3 @@ class _AlbumPickAppBar extends StatelessWidget {
]; ];
} }
} }
class _AlbumQueryBar extends StatelessWidget implements PreferredSizeWidget {
final ValueNotifier<String> queryNotifier;
static const preferredHeight = kToolbarHeight;
const _AlbumQueryBar({
required this.queryNotifier,
});
@override
Size get preferredSize => const Size.fromHeight(preferredHeight);
@override
Widget build(BuildContext context) {
return Container(
height: _AlbumQueryBar.preferredHeight,
alignment: Alignment.topCenter,
child: QueryBar(
queryNotifier: queryNotifier,
),
);
}
}

View file

@ -43,7 +43,7 @@ class AlbumListPage extends StatelessWidget {
return StreamBuilder<Set<CollectionFilter>?>( return StreamBuilder<Set<CollectionFilter>?>(
// to update sections by tier // to update sections by tier
stream: covers.packageChangeStream, stream: covers.packageChangeStream,
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>( builder: (context, snapshot) => FilterNavigationPage<AlbumFilter, AlbumChipSetActionDelegate>(
source: source, source: source,
title: context.l10n.albumPageTitle, title: context.l10n.albumPageTitle,
sortFactor: settings.albumSortFactor, sortFactor: settings.albumSortFactor,
@ -51,6 +51,7 @@ class AlbumListPage extends StatelessWidget {
actionDelegate: AlbumChipSetActionDelegate(gridItems), actionDelegate: AlbumChipSetActionDelegate(gridItems),
filterSections: groupToSections(context, source, gridItems), filterSections: groupToSections(context, source, gridItems),
newFilters: source.getNewAlbumFilters(context), newFilters: source.getNewAlbumFilters(context),
applyQuery: applyQuery,
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: context.l10n.albumEmpty, text: context.l10n.albumEmpty,
@ -67,6 +68,10 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries // common with album selection page to move/copy entries
static List<FilterGridItem<AlbumFilter>> applyQuery(BuildContext context, List<FilterGridItem<AlbumFilter>> filters, String query) {
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
}
static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) { static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) {
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet(); final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();

View file

@ -4,6 +4,7 @@ import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
@ -62,6 +63,8 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
// browsing // browsing
case ChipSetAction.search: case ChipSetAction.search:
return appMode.canNavigate && !isSelecting; return appMode.canNavigate && !isSelecting;
case ChipSetAction.toggleTitleSearch:
return !isSelecting;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
return false; return false;
// browsing or selecting // browsing or selecting
@ -104,6 +107,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
// browsing // browsing
case ChipSetAction.search: case ChipSetAction.search:
case ChipSetAction.toggleTitleSearch:
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
return true; return true;
// browsing or selecting // browsing or selecting
@ -143,6 +147,9 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.search: case ChipSetAction.search:
_goToSearch(context); _goToSearch(context);
break; break;
case ChipSetAction.toggleTitleSearch:
context.read<Query>().toggle();
break;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
break; break;
// browsing or selecting // browsing or selecting

View file

@ -1,47 +1,85 @@
import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/app_bar/title_search_toggler.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/identity/aves_app_bar.dart';
import 'package:aves/widgets/common/search/route.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/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/filter_grids/common/query_bar.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class FilterGridAppBar<T extends CollectionFilter> extends StatefulWidget { typedef ActionsBuilder<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>> = List<Widget> Function(
BuildContext context,
AppMode appMode,
Selection<FilterGridItem<T>> selection,
CSAD actionDelegate,
);
class FilterGridAppBar<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>> extends StatefulWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final ChipSetActionDelegate<T> actionDelegate; final CSAD actionDelegate;
final ActionsBuilder<T, CSAD>? actionsBuilder;
final bool isEmpty; final bool isEmpty;
final ValueNotifier<double> appBarHeightNotifier;
const FilterGridAppBar({ const FilterGridAppBar({
super.key, super.key,
required this.source, required this.source,
required this.title, required this.title,
required this.actionDelegate, required this.actionDelegate,
this.actionsBuilder,
required this.isEmpty, required this.isEmpty,
required this.appBarHeightNotifier,
}); });
@override @override
State<FilterGridAppBar<T>> createState() => _FilterGridAppBarState<T>(); State<FilterGridAppBar<T, CSAD>> createState() => _FilterGridAppBarState<T, CSAD>();
static PopupMenuItem<ChipSetAction> toMenuItem(BuildContext context, ChipSetAction action, {required bool enabled}) {
late Widget child;
switch (action) {
case ChipSetAction.toggleTitleSearch:
child = TitleSearchToggler(
queryEnabled: context.read<Query>().enabled,
isMenuItem: true,
);
break;
default:
child = MenuRow(text: action.getText(context), icon: action.getIcon());
break;
}
return PopupMenuItem(
value: action,
enabled: enabled,
child: child,
);
}
} }
class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGridAppBar<T>> with SingleTickerProviderStateMixin { class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>> extends State<FilterGridAppBar<T, CSAD>> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
final FocusNode _queryBarFocusNode = FocusNode();
late final Listenable _queryFocusRequestNotifier;
CollectionSource get source => widget.source; CollectionSource get source => widget.source;
ChipSetActionDelegate get actionDelegate => widget.actionDelegate;
static const browsingQuickActions = [ static const browsingQuickActions = [
ChipSetAction.search, ChipSetAction.search,
]; ];
@ -54,17 +92,26 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final query = context.read<Query>();
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
_queryFocusRequestNotifier = query.focusRequestNotifier;
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
_browseToSelectAnimation = AnimationController( _browseToSelectAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation, duration: context.read<DurationsData>().iconAnimation,
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange); _isSelectingNotifier.addListener(_onActivityChange);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateAppBarHeight());
} }
@override @override
void dispose() { void dispose() {
_queryFocusRequestNotifier.removeListener(_onQueryFocusRequest);
_isSelectingNotifier.removeListener(_onActivityChange); _isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose(); _browseToSelectAnimation.dispose();
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose(); super.dispose();
} }
@ -74,16 +121,33 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
final selection = context.watch<Selection<FilterGridItem<T>>>(); final selection = context.watch<Selection<FilterGridItem<T>>>();
final isSelecting = selection.isSelecting; final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting; _isSelectingNotifier.value = isSelecting;
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
ActionsBuilder<T, CSAD> actionsBuilder = widget.actionsBuilder ?? _buildActions;
return AvesAppBar( return AvesAppBar(
contentHeight: kToolbarHeight, contentHeight: appBarContentHeight,
leading: _buildAppBarLeading( leading: _buildAppBarLeading(
hasDrawer: appMode.canNavigate, hasDrawer: appMode.canNavigate,
isSelecting: isSelecting, isSelecting: isSelecting,
), ),
title: _buildAppBarTitle(isSelecting), title: _buildAppBarTitle(isSelecting),
actions: _buildActions(appMode, selection), actions: actionsBuilder(context, appMode, selection, widget.actionDelegate),
bottom: queryEnabled
? FilterQueryBar<T>(
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
focusNode: _queryBarFocusNode,
)
: null,
transitionKey: isSelecting, transitionKey: isSelecting,
); );
},
);
}
double get appBarContentHeight {
final hasQuery = context.read<Query>().enabled;
return kToolbarHeight + (hasQuery ? FilterQueryBar.preferredHeight : .0);
} }
Widget _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) { Widget _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) {
@ -136,7 +200,12 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
} }
} }
List<Widget> _buildActions(AppMode appMode, Selection<FilterGridItem<T>> selection) { List<Widget> _buildActions(
BuildContext context,
AppMode appMode,
Selection<FilterGridItem<T>> selection,
CSAD actionDelegate,
) {
final itemCount = actionDelegate.allItems.length; final itemCount = actionDelegate.allItems.length;
final isSelecting = selection.isSelecting; final isSelecting = selection.isSelecting;
final selectedItems = selection.selectedItems; final selectedItems = selection.selectedItems;
@ -157,7 +226,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
); );
final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
(action) => _toActionButton(action, enabled: canApply(action)), (action) => _toActionButton(context, actionDelegate, action, enabled: canApply(action)),
); );
return [ return [
@ -166,13 +235,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
child: PopupMenuButton<ChipSetAction>( child: PopupMenuButton<ChipSetAction>(
itemBuilder: (context) { itemBuilder: (context) {
final generalMenuItems = ChipSetActions.general.where(isVisible).map( final generalMenuItems = ChipSetActions.general.where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action)), (action) => FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)),
); );
final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v)); final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v)); final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action)), (action) => FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)),
); );
return [ return [
@ -184,29 +253,45 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
]; ];
}, },
onSelected: (action) async { onSelected: (action) async {
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
_onActionSelected(action); _onActionSelected(context, action, actionDelegate);
}, },
), ),
), ),
]; ];
} }
Widget _toActionButton(ChipSetAction action, {required bool enabled}) { Widget _toActionButton(
BuildContext context,
CSAD actionDelegate,
ChipSetAction action, {
required bool enabled,
}) {
final onPressed = enabled ? () => _onActionSelected(context, action, actionDelegate) : null;
switch (action) {
case ChipSetAction.toggleTitleSearch:
// `Query` may not be available during hero
return Selector<Query?, bool>(
selector: (context, query) => query?.enabled ?? false,
builder: (context, queryEnabled, child) {
return TitleSearchToggler(
queryEnabled: queryEnabled,
onPressed: onPressed,
);
},
);
default:
return IconButton( return IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: enabled ? () => _onActionSelected(action) : null, onPressed: onPressed,
tooltip: action.getText(context), tooltip: action.getText(context),
); );
} }
PopupMenuItem<ChipSetAction> _toMenuItem(ChipSetAction action, {required bool enabled}) {
return PopupMenuItem(
value: action,
enabled: enabled,
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
);
} }
void _onActivityChange() { void _onActivityChange() {
@ -217,7 +302,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
} }
} }
void _onActionSelected(ChipSetAction action) { void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus();
void _updateAppBarHeight() {
widget.appBarHeightNotifier.value = AvesAppBar.appBarHeightForContentHeight(appBarContentHeight);
}
void _onActionSelected(BuildContext context, ChipSetAction action, ChipSetActionDelegate<T> actionDelegate) {
final selection = context.read<Selection<FilterGridItem<T>>>(); final selection = context.read<Selection<FilterGridItem<T>>>();
final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
actionDelegate.onActionSelected(context, selectedFilters, action); actionDelegate.onActionSelected(context, selectedFilters, action);

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
@ -21,6 +22,7 @@ import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/query_provider.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
@ -38,18 +40,17 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
typedef QueryTest<T extends CollectionFilter> = Iterable<FilterGridItem<T>> Function(Iterable<FilterGridItem<T>> filters, String query); typedef QueryTest<T extends CollectionFilter> = List<FilterGridItem<T>> Function(BuildContext context, List<FilterGridItem<T>> filters, String query);
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget { class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final String? settingsRouteKey; final String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters; final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final QueryTest<T> applyQuery;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final HeroType heroType; final HeroType heroType;
final StreamController<DraggableScrollBarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast(); final StreamController<DraggableScrollBarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
@ -58,14 +59,13 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
super.key, super.key,
this.settingsRouteKey, this.settingsRouteKey,
required this.appBar, required this.appBar,
required this.appBarHeight, required this.appBarHeightNotifier,
required this.sections, required this.sections,
required this.newFilters, required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
required this.queryNotifier, required this.applyQuery,
this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.heroType, required this.heroType,
}); });
@ -84,7 +84,9 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
return false; return false;
}, },
child: Scaffold( child: Scaffold(
body: WillPopScope( body: QueryProvider(
initialQuery: null,
child: WillPopScope(
onWillPop: () { onWillPop: () {
final selection = context.read<Selection<FilterGridItem<T>>>(); final selection = context.read<Selection<FilterGridItem<T>>>();
if (selection.isSelecting) { if (selection.isSelecting) {
@ -101,6 +103,9 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.padding.top, selector: (context, mq) => mq.padding.top,
builder: (context, mqPaddingTop, child) { builder: (context, mqPaddingTop, child) {
return ValueListenableBuilder<double>(
valueListenable: appBarHeightNotifier,
builder: (context, appBarHeight, child) {
return FilterGrid<T>( return FilterGrid<T>(
// key is expected by test driver // key is expected by test driver
key: const Key('filter-grid'), key: const Key('filter-grid'),
@ -112,18 +117,20 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
sortFactor: sortFactor, sortFactor: sortFactor,
showHeaders: showHeaders, showHeaders: showHeaders,
selectable: selectable, selectable: selectable,
queryNotifier: queryNotifier,
applyQuery: applyQuery, applyQuery: applyQuery,
emptyBuilder: emptyBuilder, emptyBuilder: emptyBuilder,
heroType: heroType, heroType: heroType,
); );
}, },
);
},
), ),
), ),
), ),
), ),
), ),
drawer: const AppDrawer(), ),
drawer: canNavigate ? const AppDrawer() : null,
bottomNavigationBar: showBottomNavigationBar bottomNavigationBar: showBottomNavigationBar
? AppBottomNavBar( ? AppBottomNavBar(
events: _draggableScrollBarEventStreamController.stream, events: _draggableScrollBarEventStreamController.stream,
@ -147,8 +154,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final Set<T> newFilters; final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final QueryTest<T> applyQuery;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final HeroType heroType; final HeroType heroType;
@ -162,7 +168,6 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.heroType, required this.heroType,
@ -201,7 +206,6 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
sortFactor: widget.sortFactor, sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders, showHeaders: widget.showHeaders,
selectable: widget.selectable, selectable: widget.selectable,
queryNotifier: widget.queryNotifier,
applyQuery: widget.applyQuery, applyQuery: widget.applyQuery,
emptyBuilder: widget.emptyBuilder, emptyBuilder: widget.emptyBuilder,
heroType: widget.heroType, heroType: widget.heroType,
@ -216,9 +220,8 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final Set<T> newFilters; final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final QueryTest<T>? applyQuery; final QueryTest<T> applyQuery;
final HeroType heroType; final HeroType heroType;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@ -232,7 +235,6 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.heroType, required this.heroType,
@ -244,20 +246,23 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey; final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
final tileLayout = context.select<Settings, TileLayout>((s) => s.getTileLayout(settingsRouteKey)); final tileLayout = context.select<Settings, TileLayout>((s) => s.getTileLayout(settingsRouteKey));
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: queryNotifier, valueListenable: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
builder: (context, query, child) { builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections; Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
if (applyQuery == null) { if (queryEnabled && query.isNotEmpty) {
visibleSections = sections;
} else {
visibleSections = {}; visibleSections = {};
sections.forEach((sectionKey, sectionFilters) { sections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery!(sectionFilters, query); final visibleFilters = applyQuery(context, sectionFilters, query.toUpperCase());
if (visibleFilters.isNotEmpty) { if (visibleFilters.isNotEmpty) {
visibleSections[sectionKey] = visibleFilters.toList(); visibleSections[sectionKey] = visibleFilters;
} }
}); });
} else {
visibleSections = sections;
} }
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
@ -332,6 +337,8 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
return sectionedListLayoutProvider; return sectionedListLayoutProvider;
}, },
); );
},
);
} }
String? _getFilterBanner(BuildContext context, T filter) { String? _getFilterBanner(BuildContext context, T filter) {
@ -571,15 +578,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
return empty return empty
? SliverFillRemaining( ? SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqPaddingBottom, child) {
return Padding(
padding: EdgeInsets.only(bottom: mqPaddingBottom),
child: emptyBuilder(), child: emptyBuilder(),
);
},
),
) )
: SectionedListSliver<FilterGridItem<T>>(); : SectionedListSliver<FilterGridItem<T>>();
}), }),

View file

@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/identity/aves_app_bar.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
@ -11,14 +10,15 @@ import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget { class FilterNavigationPage<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>> extends StatefulWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders;
final ChipSetActionDelegate<T> actionDelegate; final CSAD actionDelegate;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Set<T>? newFilters; final Set<T>? newFilters;
final QueryTest<T> applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
const FilterNavigationPage({ const FilterNavigationPage({
@ -30,40 +30,12 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
required this.actionDelegate, required this.actionDelegate,
required this.filterSections, required this.filterSections,
this.newFilters, this.newFilters,
required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
}); });
@override @override
Widget build(BuildContext context) { State<FilterNavigationPage<T, CSAD>> createState() => _FilterNavigationPageState<T, CSAD>();
return SelectionProvider<FilterGridItem<T>>(
child: Builder(
builder: (context) => FilterGridPage<T>(
appBar: FilterGridAppBar<T>(
source: source,
title: title,
actionDelegate: actionDelegate,
isEmpty: filterSections.isEmpty,
),
appBarHeight: AvesAppBar.appBarHeightForContentHeight(kToolbarHeight),
sections: filterSections,
newFilters: newFilters ?? {},
sortFactor: sortFactor,
showHeaders: showHeaders,
selectable: true,
queryNotifier: ValueNotifier(''),
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox();
},
),
// do not always enable hero, otherwise unwanted hero gets triggered
// when using `Show in [...]` action from a chip in the Collection filter bar
heroType: HeroType.onTap,
),
),
);
}
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) { static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
final c = (b.entry?.bestDate ?? epoch).compareTo(a.entry?.bestDate ?? epoch); final c = (b.entry?.bestDate ?? epoch).compareTo(a.entry?.bestDate ?? epoch);
@ -79,7 +51,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return a.filter.compareTo(b.filter); return a.filter.compareTo(b.filter);
} }
static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) { static List<FilterGridItem<T>> sort<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) { List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters return filters
.map((filter) => FilterGridItem( .map((filter) => FilterGridItem(
@ -107,3 +79,40 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return allMapEntries; return allMapEntries;
} }
} }
class _FilterNavigationPageState<T extends CollectionFilter, CSAD extends ChipSetActionDelegate<T>> extends State<FilterNavigationPage<T, CSAD>> {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@override
Widget build(BuildContext context) {
return SelectionProvider<FilterGridItem<T>>(
child: Builder(
builder: (context) => FilterGridPage<T>(
appBar: FilterGridAppBar<T, CSAD>(
source: widget.source,
title: widget.title,
actionDelegate: widget.actionDelegate,
isEmpty: widget.filterSections.isEmpty,
appBarHeightNotifier: _appBarHeightNotifier,
),
appBarHeightNotifier: _appBarHeightNotifier,
sections: widget.filterSections,
newFilters: widget.newFilters ?? {},
sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders,
selectable: true,
applyQuery: widget.applyQuery,
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: widget.source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? widget.emptyBuilder() : const SizedBox();
},
),
// do not always enable hero, otherwise unwanted hero gets triggered
// when using `Show in [...]` action from a chip in the Collection filter bar
heroType: HeroType.onTap,
),
),
);
}
}

View file

@ -0,0 +1,36 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.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 FilterQueryBar<T extends CollectionFilter> extends StatelessWidget {
final ValueNotifier<String> queryNotifier;
final FocusNode focusNode;
static const preferredHeight = kToolbarHeight;
const FilterQueryBar({
super.key,
required this.queryNotifier,
required this.focusNode,
});
@override
Widget build(BuildContext context) {
return Container(
height: FilterQueryBar.preferredHeight,
alignment: Alignment.topCenter,
child: Selector<Selection<FilterGridItem<T>>, bool>(
selector: (context, selection) => !selection.isSelecting,
builder: (context, editable, child) => QueryBar(
queryNotifier: queryNotifier,
focusNode: focusNode,
hintText: context.l10n.collectionSearchTitlesHintText,
editable: editable,
),
),
);
}
}

View file

@ -35,12 +35,13 @@ class CountryListPage extends StatelessWidget {
stream: source.eventBus.on<CountriesChangedEvent>(), stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
final gridItems = _getGridItems(source); final gridItems = _getGridItems(source);
return FilterNavigationPage<LocationFilter>( return FilterNavigationPage<LocationFilter, CountryChipSetActionDelegate>(
source: source, source: source,
title: context.l10n.countryPageTitle, title: context.l10n.countryPageTitle,
sortFactor: settings.countrySortFactor, sortFactor: settings.countrySortFactor,
actionDelegate: CountryChipSetActionDelegate(gridItems), actionDelegate: CountryChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems), filterSections: _groupToSections(gridItems),
applyQuery: applyQuery,
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.location, icon: AIcons.location,
text: context.l10n.countryEmpty, text: context.l10n.countryEmpty,
@ -52,6 +53,10 @@ class CountryListPage extends StatelessWidget {
); );
} }
List<FilterGridItem<LocationFilter>> applyQuery(BuildContext context, List<FilterGridItem<LocationFilter>> filters, String query) {
return filters.where((item) => item.filter.getLabel(context).toUpperCase().contains(query)).toList();
}
List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) { List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet(); final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();

View file

@ -35,12 +35,13 @@ class TagListPage extends StatelessWidget {
stream: source.eventBus.on<TagsChangedEvent>(), stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
final gridItems = _getGridItems(source); final gridItems = _getGridItems(source);
return FilterNavigationPage<TagFilter>( return FilterNavigationPage<TagFilter, TagChipSetActionDelegate>(
source: source, source: source,
title: context.l10n.tagPageTitle, title: context.l10n.tagPageTitle,
sortFactor: settings.tagSortFactor, sortFactor: settings.tagSortFactor,
actionDelegate: TagChipSetActionDelegate(gridItems), actionDelegate: TagChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems), filterSections: _groupToSections(gridItems),
applyQuery: applyQuery,
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.tag, icon: AIcons.tag,
text: context.l10n.tagEmpty, text: context.l10n.tagEmpty,
@ -52,6 +53,10 @@ class TagListPage extends StatelessWidget {
); );
} }
List<FilterGridItem<TagFilter>> applyQuery(BuildContext context, List<FilterGridItem<TagFilter>> filters, String query) {
return filters.where((item) => item.filter.tag.toUpperCase().contains(query)).toList();
}
List<FilterGridItem<TagFilter>> _getGridItems(CollectionSource source) { List<FilterGridItem<TagFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedTags.map(TagFilter.new).toSet(); final filters = source.sortedTags.map(TagFilter.new).toSet();

View file

@ -9,7 +9,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -2,10 +2,10 @@ import 'package:aves/model/actions/entry_info_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/sliver_app_bar_title.dart'; import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/info_search.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';

View file

@ -7,7 +7,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart'; import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/favourite_toggler.dart'; import 'package:aves/widgets/common/app_bar/favourite_toggler.dart';
import 'package:aves/widgets/viewer/action/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/action/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';