#277 albums/countries/tags: show/hide title filter
This commit is contained in:
parent
daedd552e2
commit
5dc6b22fb6
23 changed files with 471 additions and 354 deletions
|
@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
|
|||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ enum ChipSetAction {
|
|||
selectNone,
|
||||
// browsing
|
||||
search,
|
||||
toggleTitleSearch,
|
||||
createAlbum,
|
||||
// browsing or selecting
|
||||
map,
|
||||
|
@ -35,6 +36,7 @@ class ChipSetActions {
|
|||
|
||||
static const browsing = [
|
||||
ChipSetAction.search,
|
||||
ChipSetAction.toggleTitleSearch,
|
||||
ChipSetAction.createAlbum,
|
||||
ChipSetAction.map,
|
||||
ChipSetAction.slideshow,
|
||||
|
@ -69,6 +71,9 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
// browsing
|
||||
case ChipSetAction.search:
|
||||
return MaterialLocalizations.of(context).searchFieldLabel;
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
// different data depending on toggle state
|
||||
return context.l10n.collectionActionShowTitleSearch;
|
||||
case ChipSetAction.createAlbum:
|
||||
return context.l10n.chipActionCreateAlbum;
|
||||
// browsing or selecting
|
||||
|
@ -111,6 +116,9 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
// browsing
|
||||
case ChipSetAction.search:
|
||||
return AIcons.search;
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
// different data depending on toggle state
|
||||
return AIcons.filter;
|
||||
case ChipSetAction.createAlbum:
|
||||
return AIcons.add;
|
||||
// browsing or selecting
|
||||
|
|
|
@ -4,6 +4,10 @@ import 'package:aves/utils/change_notifier.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Query extends ChangeNotifier {
|
||||
final AChangeNotifier _focusRequestNotifier = AChangeNotifier();
|
||||
final ValueNotifier<String> _queryNotifier = ValueNotifier('');
|
||||
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
|
||||
|
||||
Query({required String? initialValue}) {
|
||||
if (initialValue != null && initialValue.isNotEmpty) {
|
||||
_enabled = true;
|
||||
|
@ -28,11 +32,9 @@ class Query extends ChangeNotifier {
|
|||
|
||||
void toggle() => enabled = !enabled;
|
||||
|
||||
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
|
||||
|
||||
Stream<bool> get enabledStream => _enabledStreamController.stream;
|
||||
|
||||
final AChangeNotifier focusRequestNotifier = AChangeNotifier();
|
||||
AChangeNotifier get focusRequestNotifier => _focusRequestNotifier;
|
||||
|
||||
final ValueNotifier<String> queryNotifier = ValueNotifier('');
|
||||
ValueNotifier<String> get queryNotifier => _queryNotifier;
|
||||
}
|
||||
|
|
|
@ -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/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/app_bar/app_bar_subtitle.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/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';
|
||||
|
@ -142,7 +143,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
isSelecting: isSelecting,
|
||||
),
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(selection),
|
||||
actions: _buildActions(context, selection),
|
||||
bottom: Column(
|
||||
children: [
|
||||
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 selectedItemCount = selection.selectedItems.length;
|
||||
|
||||
|
@ -328,7 +329,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
return Selector<Query?, bool>(
|
||||
selector: (context, query) => query?.enabled ?? false,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return _TitleSearchToggler(
|
||||
return TitleSearchToggler(
|
||||
queryEnabled: queryEnabled,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
@ -353,7 +354,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
late Widget child;
|
||||
switch (action) {
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
child = _TitleSearchToggler(
|
||||
child = TitleSearchToggler(
|
||||
queryEnabled: context.read<Query>().enabled,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -186,52 +186,51 @@ class _CollectionSectionedContent extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
TileLayout get tileLayout => widget.tileLayout;
|
||||
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
|
||||
|
||||
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _CollectionScrollView(
|
||||
scrollableKey: scrollableKey,
|
||||
scrollableKey: _scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
collection: collection,
|
||||
),
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
);
|
||||
|
||||
final scaler = _CollectionScaler(
|
||||
scrollableKey: scrollableKey,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
tileLayout: tileLayout,
|
||||
child: scrollView,
|
||||
);
|
||||
|
||||
final selector = GridSelectionGestureDetector(
|
||||
scrollableKey: scrollableKey,
|
||||
scrollableKey: _scrollableKey,
|
||||
selectable: context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canSelectMedia),
|
||||
items: collection.sortedEntries,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
child: scaler,
|
||||
);
|
||||
|
||||
return GridItemTracker<AvesEntry>(
|
||||
scrollableKey: scrollableKey,
|
||||
scrollableKey: _scrollableKey,
|
||||
tileLayout: tileLayout,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
scrollController: scrollController,
|
||||
child: selector,
|
||||
);
|
||||
|
|
|
@ -128,7 +128,7 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
),
|
||||
),
|
||||
floatingActionButton: _buildFab(context, hasSelection),
|
||||
drawer: AppDrawer(currentCollection: _collection),
|
||||
drawer: canNavigate ? AppDrawer(currentCollection: _collection) : null,
|
||||
bottomNavigationBar: showBottomNavigationBar
|
||||
? AppBottomNavBar(
|
||||
events: _draggableScrollBarEventStreamController.stream,
|
||||
|
|
32
lib/widgets/common/app_bar/title_search_toggler.dart
Normal file
32
lib/widgets/common/app_bar/title_search_toggler.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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/filters/album.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/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/durations.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/query_bar.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/empty.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/filter_grids/albums_page.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:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -61,10 +60,25 @@ class _AlbumPickPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _AlbumPickPageState extends State<_AlbumPickPage> {
|
||||
final _queryNotifier = ValueNotifier('');
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
|
@ -79,24 +93,21 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
return SelectionProvider<FilterGridItem<AlbumFilter>>(
|
||||
child: FilterGridPage<AlbumFilter>(
|
||||
settingsRouteKey: AlbumListPage.routeName,
|
||||
appBar: _AlbumPickAppBar(
|
||||
appBar: FilterGridAppBar(
|
||||
source: source,
|
||||
moveType: widget.moveType,
|
||||
title: title,
|
||||
actionDelegate: AlbumChipSetActionDelegate(gridItems),
|
||||
queryNotifier: _queryNotifier,
|
||||
actionsBuilder: _buildActions,
|
||||
isEmpty: false,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
),
|
||||
appBarHeight: AvesAppBar.appBarHeightForContentHeight(_AlbumPickAppBar.contentHeight),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
sections: AlbumListPage.groupToSections(context, source, gridItems),
|
||||
newFilters: source.getNewAlbumFilters(context),
|
||||
sortFactor: settings.albumSortFactor,
|
||||
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
|
||||
selectable: false,
|
||||
queryNotifier: _queryNotifier,
|
||||
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();
|
||||
},
|
||||
applyQuery: AlbumListPage.applyQuery,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
|
@ -110,57 +121,15 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumPickAppBar extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final MoveType? moveType;
|
||||
final AlbumChipSetActionDelegate actionDelegate;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
|
||||
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) {
|
||||
List<Widget> _buildActions(
|
||||
BuildContext context,
|
||||
AppMode appMode,
|
||||
Selection<FilterGridItem<AlbumFilter>> selection,
|
||||
AlbumChipSetActionDelegate actionDelegate,
|
||||
) {
|
||||
return [
|
||||
if (moveType != null)
|
||||
if (widget.moveType != null)
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.add),
|
||||
onPressed: () async {
|
||||
|
@ -180,10 +149,9 @@ class _AlbumPickAppBar extends StatelessWidget {
|
|||
child: PopupMenuButton<ChipSetAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.configureView,
|
||||
child: MenuRow(text: context.l10n.menuActionConfigureView, icon: const Icon(AIcons.view)),
|
||||
),
|
||||
FilterGridAppBar.toMenuItem(context, ChipSetAction.configureView, enabled: true),
|
||||
const PopupMenuDivider(),
|
||||
FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true),
|
||||
];
|
||||
},
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
return StreamBuilder<Set<CollectionFilter>?>(
|
||||
// to update sections by tier
|
||||
stream: covers.packageChangeStream,
|
||||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
|
||||
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter, AlbumChipSetActionDelegate>(
|
||||
source: source,
|
||||
title: context.l10n.albumPageTitle,
|
||||
sortFactor: settings.albumSortFactor,
|
||||
|
@ -51,6 +51,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
actionDelegate: AlbumChipSetActionDelegate(gridItems),
|
||||
filterSections: groupToSections(context, source, gridItems),
|
||||
newFilters: source.getNewAlbumFilters(context),
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: context.l10n.albumEmpty,
|
||||
|
@ -67,6 +68,10 @@ class AlbumListPage extends StatelessWidget {
|
|||
|
||||
// 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) {
|
||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/covers.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.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';
|
||||
|
@ -62,6 +63,8 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
// browsing
|
||||
case ChipSetAction.search:
|
||||
return appMode.canNavigate && !isSelecting;
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
return !isSelecting;
|
||||
case ChipSetAction.createAlbum:
|
||||
return false;
|
||||
// browsing or selecting
|
||||
|
@ -104,6 +107,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.selectNone:
|
||||
// browsing
|
||||
case ChipSetAction.search:
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
case ChipSetAction.createAlbum:
|
||||
return true;
|
||||
// browsing or selecting
|
||||
|
@ -143,6 +147,9 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.search:
|
||||
_goToSearch(context);
|
||||
break;
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
context.read<Query>().toggle();
|
||||
break;
|
||||
case ChipSetAction.createAlbum:
|
||||
break;
|
||||
// browsing or selecting
|
||||
|
|
|
@ -1,47 +1,85 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/chip_set_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/query.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/app_bar/app_bar_subtitle.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/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/filter_grids/common/query_bar.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.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 String title;
|
||||
final ChipSetActionDelegate<T> actionDelegate;
|
||||
final CSAD actionDelegate;
|
||||
final ActionsBuilder<T, CSAD>? actionsBuilder;
|
||||
final bool isEmpty;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
|
||||
const FilterGridAppBar({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.title,
|
||||
required this.actionDelegate,
|
||||
this.actionsBuilder,
|
||||
required this.isEmpty,
|
||||
required this.appBarHeightNotifier,
|
||||
});
|
||||
|
||||
@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;
|
||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||
final FocusNode _queryBarFocusNode = FocusNode();
|
||||
late final Listenable _queryFocusRequestNotifier;
|
||||
|
||||
CollectionSource get source => widget.source;
|
||||
|
||||
ChipSetActionDelegate get actionDelegate => widget.actionDelegate;
|
||||
|
||||
static const browsingQuickActions = [
|
||||
ChipSetAction.search,
|
||||
];
|
||||
|
@ -54,17 +92,26 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final query = context.read<Query>();
|
||||
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
|
||||
_queryFocusRequestNotifier = query.focusRequestNotifier;
|
||||
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
|
||||
_browseToSelectAnimation = AnimationController(
|
||||
duration: context.read<DurationsData>().iconAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _updateAppBarHeight());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_queryFocusRequestNotifier.removeListener(_onQueryFocusRequest);
|
||||
_isSelectingNotifier.removeListener(_onActivityChange);
|
||||
_browseToSelectAnimation.dispose();
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -74,18 +121,35 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
final selection = context.watch<Selection<FilterGridItem<T>>>();
|
||||
final isSelecting = selection.isSelecting;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AvesAppBar(
|
||||
contentHeight: kToolbarHeight,
|
||||
leading: _buildAppBarLeading(
|
||||
hasDrawer: appMode.canNavigate,
|
||||
isSelecting: isSelecting,
|
||||
),
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(appMode, selection),
|
||||
transitionKey: isSelecting,
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
ActionsBuilder<T, CSAD> actionsBuilder = widget.actionsBuilder ?? _buildActions;
|
||||
return AvesAppBar(
|
||||
contentHeight: appBarContentHeight,
|
||||
leading: _buildAppBarLeading(
|
||||
hasDrawer: appMode.canNavigate,
|
||||
isSelecting: isSelecting,
|
||||
),
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double get appBarContentHeight {
|
||||
final hasQuery = context.read<Query>().enabled;
|
||||
return kToolbarHeight + (hasQuery ? FilterQueryBar.preferredHeight : .0);
|
||||
}
|
||||
|
||||
Widget _buildAppBarLeading({required bool hasDrawer, required bool isSelecting}) {
|
||||
if (!hasDrawer) {
|
||||
return const CloseButton();
|
||||
|
@ -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 isSelecting = selection.isSelecting;
|
||||
final selectedItems = selection.selectedItems;
|
||||
|
@ -157,7 +226,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
);
|
||||
|
||||
final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
|
||||
(action) => _toActionButton(action, enabled: canApply(action)),
|
||||
(action) => _toActionButton(context, actionDelegate, action, enabled: canApply(action)),
|
||||
);
|
||||
|
||||
return [
|
||||
|
@ -166,13 +235,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
child: PopupMenuButton<ChipSetAction>(
|
||||
itemBuilder: (context) {
|
||||
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 selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v));
|
||||
final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
|
||||
(action) => _toMenuItem(action, enabled: canApply(action)),
|
||||
(action) => FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)),
|
||||
);
|
||||
|
||||
return [
|
||||
|
@ -184,29 +253,45 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
];
|
||||
},
|
||||
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
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
_onActionSelected(action);
|
||||
_onActionSelected(context, action, actionDelegate);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _toActionButton(ChipSetAction action, {required bool enabled}) {
|
||||
return IconButton(
|
||||
icon: action.getIcon(),
|
||||
onPressed: enabled ? () => _onActionSelected(action) : null,
|
||||
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()),
|
||||
);
|
||||
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(
|
||||
icon: action.getIcon(),
|
||||
onPressed: onPressed,
|
||||
tooltip: action.getText(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
|
||||
actionDelegate.onActionSelected(context, selectedFilters, action);
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:aves/app_mode.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/settings/settings.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/scroll_thumb.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/tile_extent_controller.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: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 {
|
||||
final String? settingsRouteKey;
|
||||
final Widget appBar;
|
||||
final double appBarHeight;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
|
||||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final QueryTest<T> applyQuery;
|
||||
final Widget Function() emptyBuilder;
|
||||
final HeroType heroType;
|
||||
final StreamController<DraggableScrollBarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
|
||||
|
@ -58,14 +59,13 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
super.key,
|
||||
this.settingsRouteKey,
|
||||
required this.appBar,
|
||||
required this.appBarHeight,
|
||||
required this.appBarHeightNotifier,
|
||||
required this.sections,
|
||||
required this.newFilters,
|
||||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
this.applyQuery,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.heroType,
|
||||
});
|
||||
|
@ -84,46 +84,53 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
return false;
|
||||
},
|
||||
child: Scaffold(
|
||||
body: WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<FilterGridItem<T>>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.padding.top,
|
||||
builder: (context, mqPaddingTop, child) {
|
||||
return FilterGrid<T>(
|
||||
// key is expected by test driver
|
||||
key: const Key('filter-grid'),
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: mqPaddingTop + appBarHeight,
|
||||
sections: sections,
|
||||
newFilters: newFilters,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
selectable: selectable,
|
||||
queryNotifier: queryNotifier,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
heroType: heroType,
|
||||
);
|
||||
},
|
||||
body: QueryProvider(
|
||||
initialQuery: null,
|
||||
child: WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<FilterGridItem<T>>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.padding.top,
|
||||
builder: (context, mqPaddingTop, child) {
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: appBarHeightNotifier,
|
||||
builder: (context, appBarHeight, child) {
|
||||
return FilterGrid<T>(
|
||||
// key is expected by test driver
|
||||
key: const Key('filter-grid'),
|
||||
settingsRouteKey: settingsRouteKey,
|
||||
appBar: appBar,
|
||||
appBarHeight: mqPaddingTop + appBarHeight,
|
||||
sections: sections,
|
||||
newFilters: newFilters,
|
||||
sortFactor: sortFactor,
|
||||
showHeaders: showHeaders,
|
||||
selectable: selectable,
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: emptyBuilder,
|
||||
heroType: heroType,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
drawer: canNavigate ? const AppDrawer() : null,
|
||||
bottomNavigationBar: showBottomNavigationBar
|
||||
? AppBottomNavBar(
|
||||
events: _draggableScrollBarEventStreamController.stream,
|
||||
|
@ -147,8 +154,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final QueryTest<T> applyQuery;
|
||||
final Widget Function() emptyBuilder;
|
||||
final HeroType heroType;
|
||||
|
||||
|
@ -162,7 +168,6 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.heroType,
|
||||
|
@ -201,7 +206,6 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
|
|||
sortFactor: widget.sortFactor,
|
||||
showHeaders: widget.showHeaders,
|
||||
selectable: widget.selectable,
|
||||
queryNotifier: widget.queryNotifier,
|
||||
applyQuery: widget.applyQuery,
|
||||
emptyBuilder: widget.emptyBuilder,
|
||||
heroType: widget.heroType,
|
||||
|
@ -216,9 +220,8 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
final Set<T> newFilters;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders, selectable;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final Widget Function() emptyBuilder;
|
||||
final QueryTest<T>? applyQuery;
|
||||
final QueryTest<T> applyQuery;
|
||||
final HeroType heroType;
|
||||
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
|
@ -232,7 +235,6 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
required this.sortFactor,
|
||||
required this.showHeaders,
|
||||
required this.selectable,
|
||||
required this.queryNotifier,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
required this.heroType,
|
||||
|
@ -244,92 +246,97 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
|
||||
final tileLayout = context.select<Settings, TileLayout>((s) => s.getTileLayout(settingsRouteKey));
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: queryNotifier,
|
||||
builder: (context, query, child) {
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
|
||||
if (applyQuery == null) {
|
||||
visibleSections = sections;
|
||||
} else {
|
||||
visibleSections = {};
|
||||
sections.forEach((sectionKey, sectionFilters) {
|
||||
final visibleFilters = applyQuery!(sectionFilters, query);
|
||||
if (visibleFilters.isNotEmpty) {
|
||||
visibleSections[sectionKey] = visibleFilters.toList();
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
builder: (context, query, child) {
|
||||
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
|
||||
if (queryEnabled && query.isNotEmpty) {
|
||||
visibleSections = {};
|
||||
sections.forEach((sectionKey, sectionFilters) {
|
||||
final visibleFilters = applyQuery(context, sectionFilters, query.toUpperCase());
|
||||
if (visibleFilters.isNotEmpty) {
|
||||
visibleSections[sectionKey] = visibleFilters;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
visibleSections = sections;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, thumbnailExtent, child) {
|
||||
return Selector<TileExtentController, Tuple4<double, int, double, double>>(
|
||||
selector: (context, c) => Tuple4(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
final horizontalPadding = c.item4;
|
||||
// do not listen for animation delay change
|
||||
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
|
||||
return Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.textScaleFactor,
|
||||
builder: (context, textScaleFactor, child) {
|
||||
final tileHeight = CoveredFilterChip.tileHeight(
|
||||
extent: thumbnailExtent,
|
||||
textScaleFactor: textScaleFactor,
|
||||
showText: tileLayout == TileLayout.grid,
|
||||
);
|
||||
return GridTheme(
|
||||
extent: thumbnailExtent,
|
||||
child: FilterListDetailsTheme(
|
||||
extent: thumbnailExtent,
|
||||
child: SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleSections,
|
||||
showHeaders: showHeaders,
|
||||
tileLayout: tileLayout,
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tileWidth: thumbnailExtent,
|
||||
tileHeight: tileHeight,
|
||||
tileBuilder: (gridItem) {
|
||||
return InteractiveFilterTile(
|
||||
gridItem: gridItem,
|
||||
chipExtent: thumbnailExtent,
|
||||
thumbnailExtent: thumbnailExtent,
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, thumbnailExtent, child) {
|
||||
return Selector<TileExtentController, Tuple4<double, int, double, double>>(
|
||||
selector: (context, c) => Tuple4(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
final horizontalPadding = c.item4;
|
||||
// do not listen for animation delay change
|
||||
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
|
||||
return Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.textScaleFactor,
|
||||
builder: (context, textScaleFactor, child) {
|
||||
final tileHeight = CoveredFilterChip.tileHeight(
|
||||
extent: thumbnailExtent,
|
||||
textScaleFactor: textScaleFactor,
|
||||
showText: tileLayout == TileLayout.grid,
|
||||
);
|
||||
return GridTheme(
|
||||
extent: thumbnailExtent,
|
||||
child: FilterListDetailsTheme(
|
||||
extent: thumbnailExtent,
|
||||
child: SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleSections,
|
||||
showHeaders: showHeaders,
|
||||
tileLayout: tileLayout,
|
||||
banner: _getFilterBanner(context, gridItem.filter),
|
||||
heroType: heroType,
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tileWidth: thumbnailExtent,
|
||||
tileHeight: tileHeight,
|
||||
tileBuilder: (gridItem) {
|
||||
return InteractiveFilterTile(
|
||||
gridItem: gridItem,
|
||||
chipExtent: thumbnailExtent,
|
||||
thumbnailExtent: thumbnailExtent,
|
||||
tileLayout: tileLayout,
|
||||
banner: _getFilterBanner(context, gridItem.filter),
|
||||
heroType: heroType,
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleSections: visibleSections,
|
||||
sortFactor: sortFactor,
|
||||
selectable: selectable,
|
||||
emptyBuilder: emptyBuilder,
|
||||
bannerBuilder: _getFilterBanner,
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
tileLayout: tileLayout,
|
||||
),
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
child: _FilterSectionedContent<T>(
|
||||
appBar: appBar,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
visibleSections: visibleSections,
|
||||
sortFactor: sortFactor,
|
||||
selectable: selectable,
|
||||
emptyBuilder: emptyBuilder,
|
||||
bannerBuilder: _getFilterBanner,
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
tileLayout: tileLayout,
|
||||
),
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -571,15 +578,7 @@ class _FilterScrollView<T extends CollectionFilter> extends StatelessWidget {
|
|||
return empty
|
||||
? SliverFillRemaining(
|
||||
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>>();
|
||||
}),
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.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/providers/selection_provider.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: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 String title;
|
||||
final ChipSortFactor sortFactor;
|
||||
final bool showHeaders;
|
||||
final ChipSetActionDelegate<T> actionDelegate;
|
||||
final CSAD actionDelegate;
|
||||
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
|
||||
final Set<T>? newFilters;
|
||||
final QueryTest<T> applyQuery;
|
||||
final Widget Function() emptyBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
|
@ -30,40 +30,12 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
required this.actionDelegate,
|
||||
required this.filterSections,
|
||||
this.newFilters,
|
||||
required this.applyQuery,
|
||||
required this.emptyBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
State<FilterNavigationPage<T, CSAD>> createState() => _FilterNavigationPageState<T, CSAD>();
|
||||
|
||||
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return filters
|
||||
.map((filter) => FilterGridItem(
|
||||
|
@ -107,3 +79,40 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
|
|||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
36
lib/widgets/filter_grids/common/query_bar.dart
Normal file
36
lib/widgets/filter_grids/common/query_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -35,12 +35,13 @@ class CountryListPage extends StatelessWidget {
|
|||
stream: source.eventBus.on<CountriesChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = _getGridItems(source);
|
||||
return FilterNavigationPage<LocationFilter>(
|
||||
return FilterNavigationPage<LocationFilter, CountryChipSetActionDelegate>(
|
||||
source: source,
|
||||
title: context.l10n.countryPageTitle,
|
||||
sortFactor: settings.countrySortFactor,
|
||||
actionDelegate: CountryChipSetActionDelegate(gridItems),
|
||||
filterSections: _groupToSections(gridItems),
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
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) {
|
||||
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
|
||||
|
||||
|
|
|
@ -35,12 +35,13 @@ class TagListPage extends StatelessWidget {
|
|||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
final gridItems = _getGridItems(source);
|
||||
return FilterNavigationPage<TagFilter>(
|
||||
return FilterNavigationPage<TagFilter, TagChipSetActionDelegate>(
|
||||
source: source,
|
||||
title: context.l10n.tagPageTitle,
|
||||
sortFactor: settings.tagSortFactor,
|
||||
actionDelegate: TagChipSetActionDelegate(gridItems),
|
||||
filterSections: _groupToSections(gridItems),
|
||||
applyQuery: applyQuery,
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
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) {
|
||||
final filters = source.sortedTags.map(TagFilter.new).toSet();
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.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/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
|
|
@ -2,10 +2,10 @@ import 'package:aves/model/actions/entry_info_actions.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/theme/durations.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/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/info/info_search.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/basic/menu.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/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/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/notifications.dart';
|
||||
|
|
Loading…
Reference in a new issue