From f2270cfb77ab28e5412796eb3ce6e59815f87257 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 12 Jul 2021 12:07:22 +0900 Subject: [PATCH] #35 albums/countries/tags: multiple selection --- lib/l10n/app_en.arb | 14 +- lib/l10n/app_ko.arb | 7 +- lib/model/actions/chip_actions.dart | 32 +-- lib/model/actions/chip_set_actions.dart | 86 +++++++ lib/model/source/collection_source.dart | 16 +- lib/theme/icons.dart | 1 + lib/widgets/collection/app_bar.dart | 18 +- lib/widgets/collection/collection_grid.dart | 19 +- .../collection/grid/headers/album.dart | 3 +- lib/widgets/collection/grid/headers/any.dart | 5 +- lib/widgets/collection/grid/headers/date.dart | 8 +- .../collection/thumbnail/decorated.dart | 3 +- lib/widgets/collection/thumbnail/overlay.dart | 62 +---- lib/widgets/common/grid/header.dart | 24 +- lib/widgets/common/grid/overlay.dart | 66 +++++ lib/widgets/common/grid/selector.dart | 58 +++-- .../thumbnail => common/grid}/theme.dart | 12 +- .../common/identity/aves_filter_chip.dart | 80 +++--- lib/widgets/common/identity/aves_icons.dart | 22 +- lib/widgets/common/identity/aves_logo.dart | 2 - lib/widgets/common/scaling.dart | 6 +- .../dialogs/cover_selection_dialog.dart | 4 +- lib/widgets/filter_grids/album_pick.dart | 64 ++--- lib/widgets/filter_grids/albums_page.dart | 53 ++-- .../album_set.dart} | 195 ++++++-------- .../common/action_delegates/chip.dart | 75 ++++++ .../common/action_delegates/chip_set.dart | 180 +++++++++++++ .../common/action_delegates/country_set.dart | 20 ++ .../common/action_delegates/tag_set.dart | 20 ++ lib/widgets/filter_grids/common/app_bar.dart | 239 ++++++++++++++++++ .../common/chip_set_action_delegate.dart | 119 --------- ...ter_chip.dart => covered_filter_chip.dart} | 54 ++-- .../common/filter_chip_grid_decorator.dart | 43 ++++ .../filter_grids/common/filter_grid_page.dart | 210 ++++++++------- .../filter_grids/common/filter_nav_page.dart | 166 ++++-------- .../filter_grids/common/section_header.dart | 5 +- .../filter_grids/common/section_layout.dart | 2 +- lib/widgets/filter_grids/countries_page.dart | 42 ++- lib/widgets/filter_grids/tags_page.dart | 42 ++- lib/widgets/search/search_button.dart | 15 +- .../settings/privacy/hidden_filters.dart | 2 +- lib/widgets/settings/video/video.dart | 2 +- .../viewer/overlay/bottom/multipage.dart | 4 +- .../viewer/overlay/bottom/panorama.dart | 2 +- test_driver/app_test.dart | 2 +- 45 files changed, 1262 insertions(+), 842 deletions(-) create mode 100644 lib/model/actions/chip_set_actions.dart create mode 100644 lib/widgets/common/grid/overlay.dart rename lib/widgets/{collection/thumbnail => common/grid}/theme.dart (85%) rename lib/widgets/filter_grids/common/{chip_action_delegate.dart => action_delegates/album_set.dart} (52%) create mode 100644 lib/widgets/filter_grids/common/action_delegates/chip.dart create mode 100644 lib/widgets/filter_grids/common/action_delegates/chip_set.dart create mode 100644 lib/widgets/filter_grids/common/action_delegates/country_set.dart create mode 100644 lib/widgets/filter_grids/common/action_delegates/tag_set.dart create mode 100644 lib/widgets/filter_grids/common/app_bar.dart delete mode 100644 lib/widgets/filter_grids/common/chip_set_action_delegate.dart rename lib/widgets/filter_grids/common/{decorated_filter_chip.dart => covered_filter_chip.dart} (81%) create mode 100644 lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0afd10e06..365806c02 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -272,8 +272,14 @@ "renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", "@renameAlbumDialogLabelAlreadyExistsHelper": {}, - "deleteAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}", - "@deleteAlbumConfirmationDialogMessage": { + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}", + "@deleteSingleAlbumConfirmationDialogMessage": { + "placeholders": { + "count": {} + } + }, + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}", + "@deleteMultiAlbumConfirmationDialogMessage": { "placeholders": { "count": {} } @@ -630,11 +636,11 @@ "@settingsSubtitleThemeBackgroundColor": {}, "settingsSubtitleThemeBackgroundOpacity": "Background opacity", "@settingsSubtitleThemeBackgroundOpacity": {}, - "settingsSubtitleThemeTextAlignmentLeft": "Left", + "settingsSubtitleThemeTextAlignmentLeft": "Left", "@settingsSubtitleThemeTextAlignmentLeft": {}, "settingsSubtitleThemeTextAlignmentCenter": "Center", "@settingsSubtitleThemeTextAlignmentCenter": {}, - "settingsSubtitleThemeTextAlignmentRight": "Right", + "settingsSubtitleThemeTextAlignmentRight": "Right", "@settingsSubtitleThemeTextAlignmentRight": {}, "settingsSectionPrivacy": "Privacy", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 006a65387..c74d30292 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -124,7 +124,8 @@ "renameAlbumDialogLabel": "앨범 이름", "renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다", - "deleteAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", "renameEntryDialogLabel": "이름", @@ -299,9 +300,9 @@ "settingsSubtitleThemeTextOpacity": "글자 투명도", "settingsSubtitleThemeBackgroundColor": "배경 색상", "settingsSubtitleThemeBackgroundOpacity": "배경 투명도", - "settingsSubtitleThemeTextAlignmentLeft": "왼쪽", + "settingsSubtitleThemeTextAlignmentLeft": "왼쪽", "settingsSubtitleThemeTextAlignmentCenter": "가운데", - "settingsSubtitleThemeTextAlignmentRight": "오른쪽", + "settingsSubtitleThemeTextAlignmentRight": "오른쪽", "settingsSectionPrivacy": "개인정보 보호", "settingsEnableCrashReport": "오류 보고서 보내기", diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index 7e531c168..afc7e6721 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; -enum ChipSetAction { - group, - sort, - stats, -} - enum ChipAction { - delete, - hide, - pin, - unpin, - rename, - setCover, goToAlbumPage, goToCountryPage, goToTagPage, + hide, } extension ExtraChipAction on ChipAction { String getText(BuildContext context) { switch (this) { - case ChipAction.delete: - return context.l10n.chipActionDelete; case ChipAction.goToAlbumPage: return context.l10n.chipActionGoToAlbumPage; case ChipAction.goToCountryPage: @@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction { return context.l10n.chipActionGoToTagPage; case ChipAction.hide: return context.l10n.chipActionHide; - case ChipAction.pin: - return context.l10n.chipActionPin; - case ChipAction.unpin: - return context.l10n.chipActionUnpin; - case ChipAction.rename: - return context.l10n.chipActionRename; - case ChipAction.setCover: - return context.l10n.chipActionSetCover; } } IconData getIcon() { switch (this) { - case ChipAction.delete: - return AIcons.delete; case ChipAction.goToAlbumPage: return AIcons.album; case ChipAction.goToCountryPage: @@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction { return AIcons.tag; case ChipAction.hide: return AIcons.hide; - case ChipAction.pin: - case ChipAction.unpin: - return AIcons.pin; - case ChipAction.rename: - return AIcons.rename; - case ChipAction.setCover: - return AIcons.setCover; } } } diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart new file mode 100644 index 000000000..7a093da48 --- /dev/null +++ b/lib/model/actions/chip_set_actions.dart @@ -0,0 +1,86 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +enum ChipSetAction { + // general + sort, + group, + select, + selectAll, + selectNone, + stats, + // single/multiple filters + delete, + hide, + pin, + unpin, + // single filter + rename, + setCover, +} + +extension ExtraChipSetAction on ChipSetAction { + String getText(BuildContext context) { + switch (this) { + // general + case ChipSetAction.sort: + return context.l10n.menuActionSort; + case ChipSetAction.group: + return context.l10n.menuActionGroup; + case ChipSetAction.select: + return context.l10n.collectionActionSelect; + case ChipSetAction.selectAll: + return context.l10n.collectionActionSelectAll; + case ChipSetAction.selectNone: + return context.l10n.collectionActionSelectNone; + case ChipSetAction.stats: + return context.l10n.menuActionStats; + // single/multiple filters + case ChipSetAction.delete: + return context.l10n.chipActionDelete; + case ChipSetAction.hide: + return context.l10n.chipActionHide; + case ChipSetAction.pin: + return context.l10n.chipActionPin; + case ChipSetAction.unpin: + return context.l10n.chipActionUnpin; + // single filter + case ChipSetAction.rename: + return context.l10n.chipActionRename; + case ChipSetAction.setCover: + return context.l10n.chipActionSetCover; + } + } + + IconData? getIcon() { + switch (this) { + // general + case ChipSetAction.sort: + return AIcons.sort; + case ChipSetAction.group: + return AIcons.group; + case ChipSetAction.select: + return AIcons.select; + case ChipSetAction.selectAll: + case ChipSetAction.selectNone: + return null; + case ChipSetAction.stats: + return AIcons.stats; + // single/multiple filters + case ChipSetAction.delete: + return AIcons.delete; + case ChipSetAction.hide: + return AIcons.hide; + case ChipSetAction.pin: + return AIcons.pin; + case ChipSetAction.unpin: + return AIcons.unpin; + // single filter + case ChipSetAction.rename: + return AIcons.rename; + case ChipSetAction.setCover: + return AIcons.setCover; + } + } +} diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 8c0786976..01506ab53 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -275,13 +275,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return recentEntry(filter); } - void changeFilterVisibility(CollectionFilter filter, bool visible) { + void changeFilterVisibility(Set filters, bool visible) { final hiddenFilters = settings.hiddenFilters; if (visible) { - hiddenFilters.remove(filter); + hiddenFilters.removeAll(filters); } else { - hiddenFilters.add(filter); - settings.searchHistory = settings.searchHistory..remove(filter); + hiddenFilters.addAll(filters); + settings.searchHistory = settings.searchHistory..removeWhere(filters.contains); } settings.hiddenFilters = hiddenFilters; @@ -292,10 +292,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM updateLocations(); updateTags(); - eventBus.fire(FilterVisibilityChangedEvent(filter, visible)); + eventBus.fire(FilterVisibilityChangedEvent(filters, visible)); if (visible) { - refreshMetadata(visibleEntries.where(filter.test).toSet()); + refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet()); } } } @@ -319,10 +319,10 @@ class EntryMovedEvent { } class FilterVisibilityChangedEvent { - final CollectionFilter filter; + final Set filters; final bool visible; - const FilterVisibilityChangedEvent(this.filter, this.visible); + const FilterVisibilityChangedEvent(this.filters, this.visible); } class ProgressEvent { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 09d3d54b0..0e6b2a0b7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -50,6 +50,7 @@ class AIcons { static const IconData layers = Icons.layers_outlined; static const IconData openOutside = Icons.open_in_new_outlined; static const IconData pin = Icons.push_pin_outlined; + static const IconData unpin = MdiIcons.pinOffOutline; static const IconData play = Icons.play_arrow; static const IconData pause = Icons.pause; static const IconData print = Icons.print_outlined; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 78c78838f..c55c0f7e8 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -46,7 +46,6 @@ class CollectionAppBar extends StatefulWidget { } class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { - final TextEditingController _searchFieldController = TextEditingController(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); late AnimationController _browseToSelectAnimation; late Future _canAddShortcutsLoader; @@ -83,7 +82,6 @@ class _CollectionAppBarState extends State with SingleTickerPr _unregisterWidget(widget); _isSelectingNotifier.removeListener(_onActivityChange); _browseToSelectAnimation.dispose(); - _searchFieldController.dispose(); super.dispose(); } @@ -271,7 +269,6 @@ class _CollectionAppBarState extends State with SingleTickerPr _browseToSelectAnimation.forward(); } else { _browseToSelectAnimation.reverse(); - _searchFieldController.clear(); } } @@ -370,13 +367,14 @@ class _CollectionAppBarState extends State with SingleTickerPr void _goToSearch() { Navigator.push( - context, - SearchPageRoute( - delegate: CollectionSearchDelegate( - source: collection.source, - parentCollection: collection, - ), - )); + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: collection.source, + parentCollection: collection, + ), + ), + ); } void _goToStats() { diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 273f8fc32..2131ef70f 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -14,7 +14,6 @@ import 'package:aves/widgets/collection/draggable_thumb_label.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; @@ -23,6 +22,7 @@ import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; @@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), builder: (context, tileExtent, child) { - return ThumbnailTheme( + return GridTheme( extent: tileExtent, child: Selector>( selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), @@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector( selectable: isMainMode, - entries: collection.sortedEntries, + items: collection.sortedEntries, scrollController: scrollController, appBarHeightNotifier: appBarHeightNotifier, child: scaler, @@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget { ), child: child, ), - scaledBuilder: (entry, extent) => ThumbnailTheme( - extent: extent, - child: DecoratedThumbnail( - entry: entry, - tileExtent: context.read().effectiveExtentMax, - selectable: false, - highlightable: false, - ), + scaledBuilder: (entry, extent) => DecoratedThumbnail( + entry: entry, + tileExtent: context.read().effectiveExtentMax, + selectable: false, + highlightable: false, ), child: child, ); diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index 5f0c7b313..fbce99fd0 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/icons.dart'; @@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget { ); } } - return SectionHeader( + return SectionHeader( sectionKey: EntryAlbumSectionKey(directory), leading: albumIcon, title: albumName ?? context.l10n.sectionUnknown, diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index e245aec61..0253c4244 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -39,9 +40,9 @@ class CollectionSectionHeader extends StatelessWidget { case EntryGroupFactor.album: return _buildAlbumHeader(context); case EntryGroupFactor.month: - return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); + return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); case EntryGroupFactor.day: - return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); + return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); case EntryGroupFactor.none: break; } diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index 983c84a23..2670b1acf 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/common/grid/header.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -class DaySectionHeader extends StatelessWidget { +class DaySectionHeader extends StatelessWidget { final DateTime? date; const DaySectionHeader({ @@ -45,14 +45,14 @@ class DaySectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return SectionHeader( + return SectionHeader( sectionKey: EntryDateSectionKey(date), title: _formatDate(context, date), ); } } -class MonthSectionHeader extends StatelessWidget { +class MonthSectionHeader extends StatelessWidget { final DateTime? date; const MonthSectionHeader({ @@ -71,7 +71,7 @@ class MonthSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return SectionHeader( + return SectionHeader( sectionKey: EntryDateSectionKey(date), title: _formatDate(context, date), ); diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 1bccf98ab..65e40e4a5 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/collection/thumbnail/overlay.dart'; import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/grid/overlay.dart'; import 'package:flutter/material.dart'; class DecoratedThumbnail extends StatelessWidget { @@ -46,7 +47,7 @@ class DecoratedThumbnail extends StatelessWidget { children: [ child, if (!isSvg) ThumbnailEntryOverlay(entry: entry), - if (selectable) ThumbnailSelectionOverlay(entry: entry), + if (selectable) GridItemSelectionOverlay(item: entry), if (highlightable) ThumbnailHighlightOverlay(entry: entry), ], ); diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 9ecbc4895..662b24f76 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -2,11 +2,8 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/highlight.dart'; -import 'package:aves/model/selection.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -22,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final children = [ - if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), + if (entry.hasGps && context.select((t) => t.showLocation)) const GpsIcon(), if (entry.isVideo) VideoIcon( entry: entry, @@ -30,7 +27,7 @@ class ThumbnailEntryOverlay extends StatelessWidget { else if (entry.isAnimated) const AnimatedImageIcon() else ...[ - if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), + if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), if (entry.isMultiPage) MultiPageIcon(entry: entry), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), @@ -46,57 +43,6 @@ class ThumbnailEntryOverlay extends StatelessWidget { } } -class ThumbnailSelectionOverlay extends StatelessWidget { - final AvesEntry entry; - - static const duration = Durations.thumbnailOverlayAnimation; - - const ThumbnailSelectionOverlay({ - Key? key, - required this.entry, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final isSelecting = context.select, bool>((selection) => selection.isSelecting); - final child = isSelecting - ? Selector, bool>( - selector: (context, selection) => selection.isSelected([entry]), - builder: (context, isSelected, child) { - var child = isSelecting - ? OverlayIcon( - key: ValueKey(isSelected), - icon: isSelected ? AIcons.selected : AIcons.unselected, - size: context.select((t) => t.iconSize), - ) - : const SizedBox.shrink(); - child = AnimatedSwitcher( - duration: duration, - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeOutBack, - transitionBuilder: (child, animation) => ScaleTransition( - scale: animation, - child: child, - ), - child: child, - ); - child = AnimatedContainer( - duration: duration, - alignment: AlignmentDirectional.topEnd, - color: isSelected ? Colors.black54 : Colors.transparent, - child: child, - ); - return child; - }, - ) - : const SizedBox.shrink(); - return AnimatedSwitcher( - duration: duration, - child: child, - ); - } -} - class ThumbnailHighlightOverlay extends StatefulWidget { final AvesEntry entry; @@ -125,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State { decoration: BoxDecoration( border: Border.fromBorderSide(BorderSide( color: Theme.of(context).accentColor, - width: context.select((t) => t.highlightBorderWidth), + width: context.select((t) => t.highlightBorderWidth), )), ), ), diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 443614298..6d764ee22 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -1,16 +1,15 @@ -import 'package:aves/model/entry.dart'; import 'package:aves/model/selection.dart'; -import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; -class SectionHeader extends StatelessWidget { +class SectionHeader extends StatelessWidget { final SectionKey sectionKey; final Widget? leading, trailing; final String title; @@ -44,7 +43,7 @@ class SectionHeader extends StatelessWidget { children: [ WidgetSpan( alignment: widgetSpanAlignment, - child: _SectionSelectableLeading( + child: _SectionSelectableLeading( selectable: selectable, sectionKey: sectionKey, browsingBuilder: leading != null @@ -78,9 +77,8 @@ class SectionHeader extends StatelessWidget { } void _toggleSectionSelection(BuildContext context) { - final collection = context.read(); - final sectionEntries = collection.sections[sectionKey]!; - final selection = context.read>(); + final sectionEntries = context.read>().sections[sectionKey]!; + final selection = context.read>(); final isSelected = selection.isSelected(sectionEntries); if (isSelected) { selection.removeFromSelection(sectionEntries); @@ -124,7 +122,7 @@ class SectionHeader extends StatelessWidget { } } -class _SectionSelectableLeading extends StatelessWidget { +class _SectionSelectableLeading extends StatelessWidget { final bool selectable; final SectionKey sectionKey; final WidgetBuilder? browsingBuilder; @@ -144,9 +142,9 @@ class _SectionSelectableLeading extends StatelessWidget { Widget build(BuildContext context) { if (!selectable) return _buildBrowsing(context); - final isSelecting = context.select, bool>((selection) => selection.isSelecting); + final isSelecting = context.select, bool>((selection) => selection.isSelecting); final Widget child = isSelecting - ? _SectionSelectingLeading( + ? _SectionSelectingLeading( sectionKey: sectionKey, onPressed: onPressed, ) @@ -179,7 +177,7 @@ class _SectionSelectableLeading extends StatelessWidget { Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension); } -class _SectionSelectingLeading extends StatelessWidget { +class _SectionSelectingLeading extends StatelessWidget { final SectionKey sectionKey; final VoidCallback? onPressed; @@ -191,8 +189,8 @@ class _SectionSelectingLeading extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionEntries = context.watch().sections[sectionKey]!; - final selection = context.watch>(); + final sectionEntries = context.watch>().sections[sectionKey]!; + final selection = context.watch>(); final isSelected = selection.isSelected(sectionEntries); return AnimatedSwitcher( duration: Durations.sectionHeaderAnimation, diff --git a/lib/widgets/common/grid/overlay.dart b/lib/widgets/common/grid/overlay.dart new file mode 100644 index 000000000..2205cc3fe --- /dev/null +++ b/lib/widgets/common/grid/overlay.dart @@ -0,0 +1,66 @@ +import 'package:aves/model/selection.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/identity/aves_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class GridItemSelectionOverlay extends StatelessWidget { + final T item; + final BorderRadius? borderRadius; + final EdgeInsets? padding; + + static const duration = Durations.thumbnailOverlayAnimation; + + const GridItemSelectionOverlay({ + Key? key, + required this.item, + this.borderRadius, + this.padding, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isSelecting = context.select, bool>((selection) => selection.isSelecting); + final child = isSelecting + ? Selector, bool>( + selector: (context, selection) => selection.isSelected([item]), + builder: (context, isSelected, child) { + var child = isSelecting + ? OverlayIcon( + key: ValueKey(isSelected), + icon: isSelected ? AIcons.selected : AIcons.unselected, + size: context.select((t) => t.iconSize), + ) + : const SizedBox.shrink(); + child = AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeOutBack, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: child, + ), + child: child, + ); + child = AnimatedContainer( + duration: duration, + alignment: AlignmentDirectional.topEnd, + padding: padding, + decoration: BoxDecoration( + color: isSelected ? Colors.black54 : Colors.transparent, + borderRadius: borderRadius, + ), + child: child, + ); + return child; + }, + ) + : const SizedBox.shrink(); + return AnimatedSwitcher( + duration: duration, + child: child, + ); + } +} diff --git a/lib/widgets/common/grid/selector.dart b/lib/widgets/common/grid/selector.dart index 4a85d32d1..5a08db1e4 100644 --- a/lib/widgets/common/grid/selector.dart +++ b/lib/widgets/common/grid/selector.dart @@ -11,7 +11,7 @@ import 'package:provider/provider.dart'; class GridSelectionGestureDetector extends StatefulWidget { final bool selectable; - final List entries; + final List items; final ScrollController scrollController; final ValueNotifier appBarHeightNotifier; final Widget child; @@ -19,7 +19,7 @@ class GridSelectionGestureDetector extends StatefulWidget { const GridSelectionGestureDetector({ Key? key, this.selectable = true, - required this.entries, + required this.items, required this.scrollController, required this.appBarHeightNotifier, required this.child, @@ -37,7 +37,7 @@ class _GridSelectionGestureDetectorState extends State get entries => widget.entries; + List get items => widget.items; ScrollController get scrollController => widget.scrollController; @@ -49,16 +49,17 @@ class _GridSelectionGestureDetectorState extends State>(); - selection.toggleSelection(fromEntry); - _selecting = selection.isSelected([fromEntry]); - _fromIndex = entries.indexOf(fromEntry); + selection.toggleSelection(fromItem); + _selecting = selection.isSelected([fromItem]); + _fromIndex = items.indexOf(fromItem); _lastToIndex = _fromIndex; _scrollableInsets = EdgeInsets.only( top: appBarHeight, @@ -68,20 +69,29 @@ class _GridSelectionGestureDetectorState extends State, bool>((selection) => selection.isSelecting) + ? (details) { + final item = _getItemAt(details.localPosition); + if (item == null) return; + + final selection = context.read>(); + selection.toggleSelection(item); + } + : null, child: widget.child, ); } @@ -100,9 +110,9 @@ class _GridSelectionGestureDetectorState extends State extends State _onLongPressUpdate()); } } - T? _getEntryAt(Offset localPosition) { + T? _getItemAt(Offset localPosition) { // as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static, // but when it is scrolling (through controller animation), result is incomplete and children are missing, - // so we use custom layout computation instead to find the entry. + // so we use custom layout computation instead to find the item. final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; final sectionedListLayout = context.read>(); return sectionedListLayout.getItemAt(offset); @@ -148,26 +158,26 @@ class _GridSelectionGestureDetectorState extends State( + return ProxyProvider( update: (_, settings, __) { final iconSize = min(28.0, (extent / 4)).roundToDouble(); final fontSize = (iconSize / 2).floorToDouble(); final highlightBorderWidth = extent * .1; - return ThumbnailThemeData( + return GridThemeData( iconSize: iconSize, fontSize: fontSize, highlightBorderWidth: highlightBorderWidth, @@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget { } } -class ThumbnailThemeData { +class GridThemeData { final double iconSize, fontSize, highlightBorderWidth; final bool showLocation, showRaw, showVideoDuration; - const ThumbnailThemeData({ + const GridThemeData({ required this.iconSize, required this.fontSize, required this.highlightBorderWidth, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 24831d348..fde4e2165 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -209,48 +209,44 @@ class _AvesFilterChipState extends State { borderRadius: borderRadius, child: widget.background, ), - Tooltip( - message: filter.getTooltip(context), - preferBelow: false, - child: Material( - color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, - shape: RoundedRectangleBorder( - borderRadius: borderRadius, - ), - child: InkWell( - // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, - // so we get the long press details from the tap instead - onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, - onTap: onTap != null - ? () { - WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter)); - setState(() => _tapped = true); - } - : null, - onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null, - borderRadius: borderRadius, - child: FutureBuilder( - future: _colorFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - _outlineColor = snapshot.data!; + Material( + color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + child: InkWell( + // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, + // so we get the long press details from the tap instead + onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, + onTap: onTap != null + ? () { + WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter)); + setState(() => _tapped = true); } - return DecoratedBox( - decoration: BoxDecoration( - border: Border.fromBorderSide(BorderSide( - color: _outlineColor, - width: AvesFilterChip.outlineWidth, - )), - borderRadius: borderRadius, - ), - position: DecorationPosition.foreground, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: content, - ), - ); - }, - ), + : null, + onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null, + borderRadius: borderRadius, + child: FutureBuilder( + future: _colorFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + _outlineColor = snapshot.data!; + } + return DecoratedBox( + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: _outlineColor, + width: AvesFilterChip.outlineWidth, + )), + borderRadius: borderRadius, + ), + position: DecorationPosition.foreground, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: content, + ), + ); + }, ), ), ), diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 3beced0f9..fea89f1ee 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -5,7 +5,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/collection/thumbnail/theme.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final thumbnailTheme = context.watch(); - final showDuration = thumbnailTheme.showVideoDuration; + final gridTheme = context.watch(); + final showDuration = gridTheme.showVideoDuration; Widget child = OverlayIcon( icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb, - size: thumbnailTheme.iconSize, + size: gridTheme.iconSize, text: showDuration ? entry.durationText : null, iconScale: entry.is360 && showDuration ? .9 : 1, ); @@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget { child = DefaultTextStyle( style: TextStyle( color: Colors.grey.shade200, - fontSize: thumbnailTheme.fontSize, + fontSize: gridTheme.fontSize, ), child: child, ); @@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.animated, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), iconScale: .8, ); } @@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.geo, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), ); } } @@ -73,7 +73,7 @@ class SphericalImageIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.threeSixty, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), ); } } @@ -85,7 +85,7 @@ class GpsIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.location, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), ); } } @@ -97,7 +97,7 @@ class RawIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.raw, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), ); } } @@ -114,7 +114,7 @@ class MultiPageIcon extends StatelessWidget { Widget build(BuildContext context) { return OverlayIcon( icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, - size: context.select((t) => t.iconSize), + size: context.select((t) => t.iconSize), iconScale: .8, ); } diff --git a/lib/widgets/common/identity/aves_logo.dart b/lib/widgets/common/identity/aves_logo.dart index 6b7a873d7..b61fefa70 100644 --- a/lib/widgets/common/identity/aves_logo.dart +++ b/lib/widgets/common/identity/aves_logo.dart @@ -76,8 +76,6 @@ class AvesLogoPainter extends CustomPainter { path3.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false); path3.close(); - - canvas.drawPath( path0, Paint() diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 3dccaa5e0..8cf79fc6e 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,6 +2,7 @@ import 'dart:ui' as ui; import 'package:aves/model/highlight.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:collection/collection.dart'; @@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState extends State SizedBox( width: extent, height: extent, - child: widget.scaledBuilder(_metadata!.item, extent), + child: GridTheme( + extent: extent, + child: widget.scaledBuilder(_metadata!.item, extent), + ), ), center: thumbnailCenter, viewportWidth: gridWidth, diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/cover_selection_dialog.dart index 860f1c979..54aa452be 100644 --- a/lib/widgets/dialogs/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/cover_selection_dialog.dart @@ -7,7 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; -import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State { Container( alignment: Alignment.center, padding: const EdgeInsets.only(bottom: 16), - child: DecoratedFilterChip( + child: CoveredFilterChip( filter: filter, extent: extent, coverEntry: _isCustom ? _customEntry : _recentEntry, diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index f69ad73cd..76c39573a 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -1,6 +1,7 @@ -import 'package:aves/model/actions/chip_actions.dart'; +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/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -12,9 +13,10 @@ import 'package:aves/widgets/common/basic/menu_row.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/empty.dart'; +import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -46,37 +48,41 @@ class _AlbumPickPageState extends State { @override Widget build(BuildContext context) { - Widget appBar = AlbumPickAppBar( - source: source, - moveType: widget.moveType, - actionDelegate: AlbumChipSetActionDelegate(), - queryNotifier: _queryNotifier, - ); - return Selector>( selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor), builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterGridPage( - settingsRouteKey: AlbumListPage.routeName, - appBar: appBar, - appBarHeight: AlbumPickAppBar.preferredHeight, - filterSections: AlbumListPage.getAlbumEntries(context, source), - sortFactor: settings.albumSortFactor, - showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, - 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(); - }, - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: context.l10n.albumEmpty, - ), - onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter).album), - ), + builder: (context, snapshot) { + final gridItems = AlbumListPage.getAlbumGridItems(context, source); + return SelectionProvider>( + child: FilterGridPage( + settingsRouteKey: AlbumListPage.routeName, + appBar: AlbumPickAppBar( + source: source, + moveType: widget.moveType, + actionDelegate: AlbumChipSetActionDelegate(gridItems), + queryNotifier: _queryNotifier, + ), + appBarHeight: AlbumPickAppBar.preferredHeight, + sections: AlbumListPage.groupToSections(context, gridItems), + 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(); + }, + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: context.l10n.albumEmpty, + ), + onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter).album), + ), + ); + }, ); }, ); @@ -156,7 +162,7 @@ class AlbumPickAppBar extends StatelessWidget { FocusManager.instance.primaryFocus?.unfocus(); // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action)); + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action)); }, ), ], diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 836f7a7bc..f7883694e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -1,4 +1,3 @@ -import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; @@ -9,8 +8,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; @@ -38,32 +36,22 @@ class AlbumListPage extends StatelessWidget { animation: androidFileUtils.appNameChangeNotifier, builder: (context, child) => StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( - source: source, - title: context.l10n.albumPageTitle, - sortFactor: settings.albumSortFactor, - groupable: true, - showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, - chipSetActionDelegate: AlbumChipSetActionDelegate(), - chipActionDelegate: AlbumChipActionDelegate(), - chipActionsBuilder: (filter) { - final dir = VolumeRelativeDirectory.fromPath(filter.album); - // do not allow renaming volume root - final canRename = dir != null && dir.relativeDir.isNotEmpty; - return [ - settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, - ChipAction.setCover, - if (canRename) ChipAction.rename, - ChipAction.delete, - ChipAction.hide, - ]; - }, - filterSections: getAlbumEntries(context, source), - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: context.l10n.albumEmpty, - ), - ), + builder: (context, snapshot) { + final gridItems = getAlbumGridItems(context, source); + return FilterNavigationPage( + source: source, + title: context.l10n.albumPageTitle, + sortFactor: settings.albumSortFactor, + groupable: true, + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, + actionDelegate: AlbumChipSetActionDelegate(gridItems), + filterSections: groupToSections(context, gridItems), + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: context.l10n.albumEmpty, + ), + ); + }, ), ); }, @@ -72,14 +60,13 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static Map>> getAlbumEntries(BuildContext context, CollectionSource source) { + static List> getAlbumGridItems(BuildContext context, CollectionSource source) { final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet(); - final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); - return _group(context, sorted); + return FilterNavigationPage.sort(settings.albumSortFactor, source, filters); } - static Map>> _group(BuildContext context, Iterable> sortedMapEntries) { + static Map>> groupToSections(BuildContext context, Iterable> sortedMapEntries) { final pinned = settings.pinnedFilters.whereType(); final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = byPin[true] ?? []; diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart similarity index 52% rename from lib/widgets/filter_grids/common/chip_action_delegate.dart rename to lib/widgets/filter_grids/common/action_delegates/album_set.dart index 6e1766157..89d2d4c96 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -1,143 +1,112 @@ import 'dart:io'; -import 'package:aves/model/actions/chip_actions.dart'; +import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; -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/highlight.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/common/action_mixins/feedback.dart'; -import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; -import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; -import 'package:aves/widgets/filter_grids/albums_page.dart'; -import 'package:aves/widgets/filter_grids/countries_page.dart'; -import 'package:aves/widgets/filter_grids/tags_page.dart'; -import 'package:collection/collection.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; -class ChipActionDelegate { - void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { - switch (action) { - case ChipAction.pin: - settings.pinnedFilters = settings.pinnedFilters..add(filter); - break; - case ChipAction.unpin: - settings.pinnedFilters = settings.pinnedFilters..remove(filter); - break; - case ChipAction.hide: - _hide(context, filter); - break; - case ChipAction.setCover: - _showCoverSelectionDialog(context, filter); - break; - case ChipAction.goToAlbumPage: - _goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage()); - break; - case ChipAction.goToCountryPage: - _goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage()); - break; - case ChipAction.goToTagPage: - _goTo(context, filter, TagListPage.routeName, (context) => const TagListPage()); - break; - default: - break; - } - } +class AlbumChipSetActionDelegate extends ChipSetActionDelegate { + final Iterable> _items; - Future _hide(BuildContext context, CollectionFilter filter) async { - final confirmed = await showDialog( - context: context, - builder: (context) { - return AvesDialog( - context: context, - content: Text(context.l10n.hideFilterConfirmationDialogMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.hideButtonLabel), - ), - ], - ); - }, - ); - if (confirmed == null || !confirmed) return; + AlbumChipSetActionDelegate(Iterable> items) : _items = items; - final source = context.read(); - source.changeFilterVisibility(filter, false); - } - - void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async { - final contentId = covers.coverContentId(filter); - final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); - final coverSelection = await showDialog>( - context: context, - builder: (context) => CoverSelectionDialog( - filter: filter, - customEntry: customEntry, - ), - ); - if (coverSelection == null) return; - - final isCustom = coverSelection.item1; - await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null); - } - - void _goTo( - BuildContext context, - CollectionFilter filter, - String routeName, - WidgetBuilder pageBuilder, - ) { - context.read().set(filter); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: pageBuilder, - ), - (route) => false, - ); - } -} - -class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { @override - void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { - super.onActionSelected(context, filter, action); + Iterable> get allItems => _items; + + @override + ChipSortFactor get sortFactor => settings.albumSortFactor; + + @override + set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; + + @override + bool isValid(Set filters, ChipSetAction action) { switch (action) { - case ChipAction.delete: - _showDeleteDialog(context, filter as AlbumFilter); + case ChipSetAction.delete: + case ChipSetAction.rename: + return true; + default: + return super.isValid(filters, action); + } + } + + @override + bool canApply(Set filters, ChipSetAction action) { + switch (action) { + case ChipSetAction.rename: + { + if (filters.length != 1) return false; + // do not allow renaming volume root + final dir = VolumeRelativeDirectory.fromPath(filters.first.album); + return dir != null && dir.relativeDir.isNotEmpty; + } + default: + return super.canApply(filters, action); + } + } + + @override + void onActionSelected(BuildContext context, Set filters, ChipSetAction action) { + switch (action) { + // general + case ChipSetAction.group: + _showGroupDialog(context); break; - case ChipAction.rename: - _showRenameDialog(context, filter as AlbumFilter); + // single/multiple filters + case ChipSetAction.delete: + _showDeleteDialog(context, filters); + break; + // single filter + case ChipSetAction.rename: + _showRenameDialog(context, filters.first); break; default: break; } + super.onActionSelected(context, filters, action); } - Future _showDeleteDialog(BuildContext context, AlbumFilter filter) async { + Future _showGroupDialog(BuildContext context) async { + final factor = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: settings.albumGroupFactor, + options: { + AlbumChipGroupFactor.importance: context.l10n.albumGroupTier, + AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume, + AlbumChipGroupFactor.none: context.l10n.albumGroupNone, + }, + title: context.l10n.albumGroupTitle, + ), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (factor != null) { + settings.albumGroupFactor = factor; + } + } + + Future _showDeleteDialog(BuildContext context, Set filters) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); final source = context.read(); - final album = filter.album; - final todoEntries = source.visibleEntries.where(filter.test).toSet(); + final albums = filters.map((v) => v.album).toSet(); + final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); final todoCount = todoEntries.length; final confirmed = await showDialog( @@ -145,7 +114,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per builder: (context) { return AvesDialog( context: context, - content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)), + content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -161,7 +130,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermissionForAlbums(context, {album})) return; + if (!await checkStoragePermissionForAlbums(context, albums)) return; source.pauseMonitoring(); showOpReport( @@ -180,7 +149,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } // cleanup - await storageService.deleteEmptyDirectories({album}); + await storageService.deleteEmptyDirectories(albums); }, ); } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip.dart b/lib/widgets/filter_grids/common/action_delegates/chip.dart new file mode 100644 index 000000000..229709bae --- /dev/null +++ b/lib/widgets/filter_grids/common/action_delegates/chip.dart @@ -0,0 +1,75 @@ +import 'package:aves/model/actions/chip_actions.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/highlight.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:aves/widgets/filter_grids/countries_page.dart'; +import 'package:aves/widgets/filter_grids/tags_page.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ChipActionDelegate { + void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { + switch (action) { + case ChipAction.hide: + _hide(context, filter); + break; + case ChipAction.goToAlbumPage: + _goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage()); + break; + case ChipAction.goToCountryPage: + _goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage()); + break; + case ChipAction.goToTagPage: + _goTo(context, filter, TagListPage.routeName, (context) => const TagListPage()); + break; + default: + break; + } + } + + Future _hide(BuildContext context, CollectionFilter filter) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.hideFilterConfirmationDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.hideButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + final source = context.read(); + source.changeFilterVisibility({filter}, false); + } + + void _goTo( + BuildContext context, + CollectionFilter filter, + String routeName, + WidgetBuilder pageBuilder, + ) { + context.read().set(filter); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: pageBuilder, + ), + (route) => false, + ); + } +} diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart new file mode 100644 index 000000000..c8109d735 --- /dev/null +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -0,0 +1,180 @@ +import 'package:aves/model/actions/chip_set_actions.dart'; +import 'package:aves/model/covers.dart'; +import 'package:aves/model/entry.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/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; +import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; +import 'package:aves/widgets/stats/stats.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +abstract class ChipSetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { + Iterable> get allItems; + + ChipSortFactor get sortFactor; + + set sortFactor(ChipSortFactor factor); + + bool isValid(Set filters, ChipSetAction action) { + final hasSelection = filters.isNotEmpty; + switch (action) { + case ChipSetAction.delete: + case ChipSetAction.rename: + return false; + case ChipSetAction.pin: + return !hasSelection || !settings.pinnedFilters.containsAll(filters); + case ChipSetAction.unpin: + return hasSelection && settings.pinnedFilters.containsAll(filters); + default: + return true; + } + } + + bool canApply(Set filters, ChipSetAction action) { + switch (action) { + // general + case ChipSetAction.sort: + case ChipSetAction.group: + case ChipSetAction.select: + case ChipSetAction.selectAll: + case ChipSetAction.selectNone: + case ChipSetAction.stats: + return true; + // single/multiple filters + case ChipSetAction.delete: + case ChipSetAction.hide: + case ChipSetAction.pin: + case ChipSetAction.unpin: + return filters.isNotEmpty; + // single filter + case ChipSetAction.rename: + case ChipSetAction.setCover: + return filters.length == 1; + } + } + + void onActionSelected(BuildContext context, Set filters, ChipSetAction action) { + switch (action) { + // general + case ChipSetAction.sort: + _showSortDialog(context); + break; + case ChipSetAction.stats: + _goToStats(context); + break; + case ChipSetAction.select: + context.read>>().select(); + break; + case ChipSetAction.selectAll: + context.read>>().addToSelection(allItems); + break; + case ChipSetAction.selectNone: + context.read>>().clearSelection(); + break; + // single/multiple filters + case ChipSetAction.pin: + settings.pinnedFilters = settings.pinnedFilters..addAll(filters); + break; + case ChipSetAction.unpin: + settings.pinnedFilters = settings.pinnedFilters..removeAll(filters); + break; + case ChipSetAction.hide: + _hide(context, filters); + break; + // single filter + case ChipSetAction.setCover: + _showCoverSelectionDialog(context, filters.first); + break; + default: + break; + } + } + + Future _showSortDialog(BuildContext context) async { + final factor = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: sortFactor, + options: { + ChipSortFactor.date: context.l10n.chipSortDate, + ChipSortFactor.name: context.l10n.chipSortName, + ChipSortFactor.count: context.l10n.chipSortCount, + }, + title: context.l10n.chipSortTitle, + ), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (factor != null) { + sortFactor = factor; + } + } + + void _goToStats(BuildContext context) { + final source = context.read(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: StatsPage.routeName), + builder: (context) => StatsPage( + source: source, + ), + ), + ); + } + + Future _hide(BuildContext context, Set filters) async { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.hideFilterConfirmationDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.hideButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + final source = context.read(); + source.changeFilterVisibility(filters, false); + } + + void _showCoverSelectionDialog(BuildContext context, T filter) async { + final contentId = covers.coverContentId(filter); + final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); + final coverSelection = await showDialog>( + context: context, + builder: (context) => CoverSelectionDialog( + filter: filter, + customEntry: customEntry, + ), + ); + if (coverSelection == null) return; + + final isCustom = coverSelection.item1; + await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null); + } +} diff --git a/lib/widgets/filter_grids/common/action_delegates/country_set.dart b/lib/widgets/filter_grids/common/action_delegates/country_set.dart new file mode 100644 index 000000000..b9a212372 --- /dev/null +++ b/lib/widgets/filter_grids/common/action_delegates/country_set.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; + +class CountryChipSetActionDelegate extends ChipSetActionDelegate { + final Iterable> _items; + + CountryChipSetActionDelegate(Iterable> items) : _items = items; + + @override + Iterable> get allItems => _items; + + @override + ChipSortFactor get sortFactor => settings.countrySortFactor; + + @override + set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor; +} diff --git a/lib/widgets/filter_grids/common/action_delegates/tag_set.dart b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart new file mode 100644 index 000000000..fc4db32f2 --- /dev/null +++ b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; + +class TagChipSetActionDelegate extends ChipSetActionDelegate { + final Iterable> _items; + + TagChipSetActionDelegate(Iterable> items) : _items = items; + + @override + Iterable> get allItems => _items; + + @override + ChipSortFactor get sortFactor => settings.tagSortFactor; + + @override + set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor; +} diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart new file mode 100644 index 000000000..4fb57ba99 --- /dev/null +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -0,0 +1,239 @@ +import 'dart:ui'; + +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/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/basic/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; +import 'package:aves/widgets/search/search_button.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class FilterGridAppBar extends StatefulWidget { + final CollectionSource source; + final String title; + final ChipSetActionDelegate actionDelegate; + final bool groupable, isEmpty; + + const FilterGridAppBar({ + Key? key, + required this.source, + required this.title, + required this.actionDelegate, + required this.groupable, + required this.isEmpty, + }) : super(key: key); + + @override + _FilterGridAppBarState createState() => _FilterGridAppBarState(); +} + +class _FilterGridAppBarState extends State> with SingleTickerProviderStateMixin { + late AnimationController _browseToSelectAnimation; + final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + + CollectionSource get source => widget.source; + + ChipSetActionDelegate get actionDelegate => widget.actionDelegate; + + static const filterSelectionActions = [ + ChipSetAction.setCover, + ChipSetAction.pin, + ChipSetAction.unpin, + ChipSetAction.delete, + ChipSetAction.rename, + ChipSetAction.hide, + ]; + static const buttonActionCount = 2; + + @override + void initState() { + super.initState(); + _browseToSelectAnimation = AnimationController( + duration: Durations.iconAnimation, + vsync: this, + ); + _isSelectingNotifier.addListener(_onActivityChange); + } + + @override + void dispose() { + _isSelectingNotifier.removeListener(_onActivityChange); + _browseToSelectAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appMode = context.watch>().value; + final selection = context.watch>>(); + final isSelecting = selection.isSelecting; + _isSelectingNotifier.value = isSelecting; + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: _buildAppBarTitle(isSelecting), + actions: _buildActions(appMode, selection), + titleSpacing: 0, + floating: true, + ); + } + + Widget _buildAppBarLeading(bool isSelecting) { + VoidCallback? onPressed; + String? tooltip; + if (isSelecting) { + onPressed = () => context.read>>().browse(); + tooltip = MaterialLocalizations.of(context).backButtonTooltip; + } else { + onPressed = Scaffold.of(context).openDrawer; + tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; + } + return IconButton( + key: const Key('appbar-leading-button'), + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: _browseToSelectAnimation, + ), + onPressed: onPressed, + tooltip: tooltip, + ); + } + + Widget? _buildAppBarTitle(bool isSelecting) { + if (isSelecting) { + return Selector>, int>( + selector: (context, selection) => selection.selection.length, + builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)), + ); + } else { + final appMode = context.watch>().value; + return InteractiveAppBarTitle( + onTap: appMode.canSearch ? _goToSearch : null, + child: SourceStateAwareAppBarTitle( + title: Text(widget.title), + source: source, + ), + ); + } + } + + List _buildActions(AppMode appMode, Selection> selection) { + final selectedFilters = selection.selection.map((v) => v.filter).toSet(); + + PopupMenuItem toMenuItem(ChipSetAction action, {bool enabled = true}) { + return PopupMenuItem( + value: action, + enabled: enabled && actionDelegate.canApply(selectedFilters, action), + child: MenuRow( + text: action.getText(context), + icon: action.getIcon(), + ), + ); + } + + void applyAction(ChipSetAction action) { + actionDelegate.onActionSelected(context, selectedFilters, action); + if (filterSelectionActions.contains(action)) { + selection.browse(); + } + } + + final isSelecting = selection.isSelecting; + final selectionRowActions = []; + + final buttonActions = []; + if (isSelecting) { + final selectedFilters = selection.selection.map((v) => v.filter).toSet(); + final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList(); + buttonActions.addAll(validActions.take(buttonActionCount).map( + (action) { + final enabled = actionDelegate.canApply(selectedFilters, action); + return IconButton( + icon: Icon(action.getIcon()), + onPressed: enabled ? () => applyAction(action) : null, + tooltip: action.getText(context), + ); + }, + )); + selectionRowActions.addAll(validActions.skip(buttonActionCount)); + } else if (appMode.canSearch) { + buttonActions.add(CollectionSearchButton(source: source)); + } + + return [ + ...buttonActions, + PopupMenuButton( + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + final menuItems = >[ + toMenuItem(ChipSetAction.sort), + if (widget.groupable) toMenuItem(ChipSetAction.group), + ]; + + if (isSelecting) { + final selectedItems = selection.selection; + + if (selectionRowActions.isNotEmpty) { + menuItems.add(const PopupMenuDivider()); + menuItems.addAll(selectionRowActions.map(toMenuItem)); + } + + menuItems.addAll([ + const PopupMenuDivider(), + toMenuItem( + ChipSetAction.selectAll, + enabled: selectedItems.length < actionDelegate.allItems.length, + ), + toMenuItem( + ChipSetAction.selectNone, + enabled: selectedItems.isNotEmpty, + ), + ]); + } else if (appMode == AppMode.main) { + menuItems.addAll([ + toMenuItem( + ChipSetAction.select, + enabled: !widget.isEmpty, + ), + toMenuItem(ChipSetAction.stats), + ]); + } + + return menuItems; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action)); + }, + ), + ]; + } + + void _onActivityChange() { + if (context.read>>().isSelecting) { + _browseToSelectAnimation.forward(); + } else { + _browseToSelectAnimation.reverse(); + } + } + + void _goToSearch() { + Navigator.push( + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: source, + ), + ), + ); + } +} diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart deleted file mode 100644 index 137a87e81..000000000 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:aves/model/actions/chip_actions.dart'; -import 'package:aves/model/settings/settings.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/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/stats/stats.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -abstract class ChipSetActionDelegate { - ChipSortFactor get sortFactor; - - set sortFactor(ChipSortFactor factor); - - void onActionSelected(BuildContext context, ChipSetAction action) { - switch (action) { - case ChipSetAction.sort: - _showSortDialog(context); - break; - case ChipSetAction.stats: - _goToStats(context); - break; - default: - break; - } - } - - Future _showSortDialog(BuildContext context) async { - final factor = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: sortFactor, - options: { - ChipSortFactor.date: context.l10n.chipSortDate, - ChipSortFactor.name: context.l10n.chipSortName, - ChipSortFactor.count: context.l10n.chipSortCount, - }, - title: context.l10n.chipSortTitle, - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (factor != null) { - sortFactor = factor; - } - } - - void _goToStats(BuildContext context) { - final source = context.read(); - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: StatsPage.routeName), - builder: (context) => StatsPage( - source: source, - ), - ), - ); - } -} - -class AlbumChipSetActionDelegate extends ChipSetActionDelegate { - @override - ChipSortFactor get sortFactor => settings.albumSortFactor; - - @override - set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; - - @override - void onActionSelected(BuildContext context, ChipSetAction action) { - switch (action) { - case ChipSetAction.group: - _showGroupDialog(context); - break; - default: - break; - } - super.onActionSelected(context, action); - } - - Future _showGroupDialog(BuildContext context) async { - final factor = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.albumGroupFactor, - options: { - AlbumChipGroupFactor.importance: context.l10n.albumGroupTier, - AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume, - AlbumChipGroupFactor.none: context.l10n.albumGroupNone, - }, - title: context.l10n.albumGroupTitle, - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (factor != null) { - settings.albumGroupFactor = factor; - } - } -} - -class CountryChipSetActionDelegate extends ChipSetActionDelegate { - @override - ChipSortFactor get sortFactor => settings.countrySortFactor; - - @override - set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor; -} - -class TagChipSetActionDelegate extends ChipSetActionDelegate { - @override - ChipSortFactor get sortFactor => settings.tagSortFactor; - - @override - set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor; -} diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart similarity index 81% rename from lib/widgets/filter_grids/common/decorated_filter_chip.dart rename to lib/widgets/filter_grids/common/covered_filter_chip.dart index a2b4f235a..12562aa8d 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -16,29 +16,25 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; -import 'package:aves/widgets/filter_grids/common/overlay.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class DecoratedFilterChip extends StatelessWidget { - final CollectionFilter filter; +class CoveredFilterChip extends StatelessWidget { + final T filter; final double extent, thumbnailExtent; final AvesEntry? coverEntry; - final bool pinned, highlightable; + final bool pinned; final FilterCallback? onTap; - final OffsetFilterCallback? onLongPress; - const DecoratedFilterChip({ + const CoveredFilterChip({ Key? key, required this.filter, required this.extent, double? thumbnailExtent, this.coverEntry, this.pinned = false, - this.highlightable = true, this.onTap, - this.onLongPress, }) : thumbnailExtent = thumbnailExtent ?? extent, super(key: key); @@ -89,41 +85,23 @@ class DecoratedFilterChip extends StatelessWidget { extent: thumbnailExtent, ); final titlePadding = min(4.0, extent / 32); - final borderRadius = BorderRadius.all(radius(extent)); - Widget child = AvesFilterChip( - filter: filter, - showGenericIcon: false, - background: backgroundImage, - details: _buildDetails(source, filter), - borderRadius: borderRadius, - padding: titlePadding, - onTap: onTap, - onLongPress: onLongPress, - ); - - child = Stack( - fit: StackFit.passthrough, - children: [ - child, - if (highlightable) - ChipHighlightOverlay( - filter: filter, - extent: extent, - borderRadius: borderRadius, - ), - ], - ); - - child = SizedBox( + return SizedBox( width: extent, height: extent, - child: child, + child: AvesFilterChip( + filter: filter, + showGenericIcon: false, + background: backgroundImage, + details: _buildDetails(source, filter), + borderRadius: BorderRadius.all(radius(extent)), + padding: titlePadding, + onTap: onTap, + onLongPress: null, + ), ); - - return child; } - Widget _buildDetails(CollectionSource source, CollectionFilter filter) { + Widget _buildDetails(CollectionSource source, T filter) { final padding = min(8.0, extent / 16); final iconSize = min(14.0, extent / 8); final fontSize = min(14.0, extent / 6); diff --git a/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart b/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart new file mode 100644 index 000000000..04b07f33d --- /dev/null +++ b/lib/widgets/filter_grids/common/filter_chip_grid_decorator.dart @@ -0,0 +1,43 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/widgets/common/grid/overlay.dart'; +import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/overlay.dart'; +import 'package:flutter/widgets.dart'; + +class FilterChipGridDecorator> extends StatelessWidget { + final U gridItem; + final double extent; + final Widget child; + + const FilterChipGridDecorator({ + Key? key, + required this.gridItem, + required this.extent, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.all(CoveredFilterChip.radius(extent)); + return SizedBox( + width: extent, + height: extent, + child: Stack( + fit: StackFit.passthrough, + children: [ + child, + GridItemSelectionOverlay>( + item: gridItem, + borderRadius: borderRadius, + padding: EdgeInsets.all(extent / 24), + ), + ChipHighlightOverlay( + filter: gridItem.filter, + extent: extent, + borderRadius: borderRadius, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 5ac48b158..e39cd6190 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -1,8 +1,10 @@ import 'dart:ui'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; @@ -13,7 +15,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; +import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; +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'; @@ -21,8 +25,9 @@ import 'package:aves/widgets/common/providers/tile_extent_controller_provider.da import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; -import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; +import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart'; +import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_layout.dart'; import 'package:collection/collection.dart'; @@ -38,28 +43,27 @@ class FilterGridPage extends StatelessWidget { final String? settingsRouteKey; final Widget appBar; final double appBarHeight; - final Map>> filterSections; + final Map>> sections; final ChipSortFactor sortFactor; - final bool showHeaders; + final bool showHeaders, selectable; final ValueNotifier queryNotifier; final QueryTest? applyQuery; final Widget Function() emptyBuilder; final FilterCallback onTap; - final OffsetFilterCallback? onLongPress; const FilterGridPage({ Key? key, this.settingsRouteKey, required this.appBar, this.appBarHeight = kToolbarHeight, - required this.filterSections, + required this.sections, required this.sortFactor, required this.showHeaders, + required this.selectable, required this.queryNotifier, this.applyQuery, required this.emptyBuilder, required this.onTap, - this.onLongPress, }) : super(key: key); static const Color detailColor = Color(0xFFE0E0E0); @@ -68,24 +72,34 @@ class FilterGridPage extends StatelessWidget { Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: AnimatedBuilder( - animation: covers, - builder: (context, child) => FilterGrid( - settingsRouteKey: settingsRouteKey, - appBar: appBar, - appBarHeight: appBarHeight, - filterSections: filterSections, - sortFactor: sortFactor, - showHeaders: showHeaders, - queryNotifier: queryNotifier, - applyQuery: applyQuery, - emptyBuilder: emptyBuilder, - onTap: onTap, - onLongPress: onLongPress, + body: WillPopScope( + onWillPop: () { + final selection = context.read>>(); + if (selection.isSelecting) { + selection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: AnimatedBuilder( + animation: covers, + builder: (context, child) => FilterGrid( + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: appBarHeight, + sections: sections, + sortFactor: sortFactor, + showHeaders: showHeaders, + selectable: selectable, + queryNotifier: queryNotifier, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + onTap: onTap, + ), ), ), ), @@ -102,28 +116,27 @@ class FilterGrid extends StatefulWidget { final String? settingsRouteKey; final Widget appBar; final double appBarHeight; - final Map>> filterSections; + final Map>> sections; final ChipSortFactor sortFactor; - final bool showHeaders; + final bool showHeaders, selectable; final ValueNotifier queryNotifier; final QueryTest? applyQuery; final Widget Function() emptyBuilder; final FilterCallback onTap; - final OffsetFilterCallback? onLongPress; const FilterGrid({ Key? key, required this.settingsRouteKey, required this.appBar, required this.appBarHeight, - required this.filterSections, + required this.sections, required this.sortFactor, required this.showHeaders, + required this.selectable, required this.queryNotifier, required this.applyQuery, required this.emptyBuilder, required this.onTap, - required this.onLongPress, }) : super(key: key); @override @@ -152,14 +165,14 @@ class _FilterGridState extends State> child: _FilterGridContent( appBar: widget.appBar, appBarHeight: widget.appBarHeight, - filterSections: widget.filterSections, + sections: widget.sections, sortFactor: widget.sortFactor, showHeaders: widget.showHeaders, + selectable: widget.selectable, queryNotifier: widget.queryNotifier, applyQuery: widget.applyQuery, emptyBuilder: widget.emptyBuilder, onTap: widget.onTap, - onLongPress: widget.onLongPress, ), ); } @@ -167,14 +180,13 @@ class _FilterGridState extends State> class _FilterGridContent extends StatelessWidget { final Widget appBar; - final Map>> filterSections; + final Map>> sections; final ChipSortFactor sortFactor; - final bool showHeaders; + final bool showHeaders, selectable; final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; final QueryTest? applyQuery; final FilterCallback onTap; - final OffsetFilterCallback? onLongPress; final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); @@ -182,14 +194,14 @@ class _FilterGridContent extends StatelessWidget { Key? key, required this.appBar, required double appBarHeight, - required this.filterSections, + required this.sections, required this.sortFactor, required this.showHeaders, + required this.selectable, required this.queryNotifier, required this.applyQuery, required this.emptyBuilder, required this.onTap, - required this.onLongPress, }) : super(key: key) { _appBarHeightNotifier.value = appBarHeight; } @@ -199,15 +211,15 @@ class _FilterGridContent extends StatelessWidget { return ValueListenableBuilder( valueListenable: queryNotifier, builder: (context, query, child) { - Map>> visibleFilterSections; + Map>> visibleSections; if (applyQuery == null) { - visibleFilterSections = filterSections; + visibleSections = sections; } else { - visibleFilterSections = {}; - filterSections.forEach((sectionKey, sectionFilters) { + visibleSections = {}; + sections.forEach((sectionKey, sectionFilters) { final visibleFilters = applyQuery!(sectionFilters, query); if (visibleFilters.isNotEmpty) { - visibleFilterSections[sectionKey] = visibleFilters.toList(); + visibleSections[sectionKey] = visibleFilters.toList(); } }); } @@ -216,48 +228,54 @@ class _FilterGridContent extends StatelessWidget { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), builder: (context, tileExtent, child) { - return Selector>( - selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), - builder: (context, c, child) { - final scrollableWidth = c.item1; - final columnCount = c.item2; - final tileSpacing = c.item3; - // do not listen for animation delay change - final controller = Provider.of(context, listen: false); - final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); - return SectionedFilterListLayoutProvider( - sections: visibleFilterSections, - showHeaders: showHeaders, - scrollableWidth: scrollableWidth, - columnCount: columnCount, - spacing: tileSpacing, - tileExtent: tileExtent, - tileBuilder: (gridItem) { - final filter = gridItem.filter; - final entry = gridItem.entry; - return MetaData( - metaData: ScalerMetadata(FilterGridItem(filter, entry)), - child: DecoratedFilterChip( - key: Key(filter.key), - filter: filter, - extent: tileExtent, - pinned: pinnedFilters.contains(filter), - onTap: onTap, - onLongPress: onLongPress, - ), - ); - }, - tileAnimationDelay: tileAnimationDelay, - child: _FilterSectionedContent( - appBar: appBar, - appBarHeightNotifier: _appBarHeightNotifier, - visibleFilterSections: visibleFilterSections, - sortFactor: sortFactor, - emptyBuilder: emptyBuilder, - scrollController: PrimaryScrollController.of(context)!, - ), - ); - }); + return GridTheme( + extent: tileExtent, + child: Selector>( + selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), + builder: (context, c, child) { + final scrollableWidth = c.item1; + final columnCount = c.item2; + final tileSpacing = c.item3; + // do not listen for animation delay change + final controller = Provider.of(context, listen: false); + final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); + return SectionedFilterListLayoutProvider( + sections: visibleSections, + showHeaders: showHeaders, + scrollableWidth: scrollableWidth, + columnCount: columnCount, + spacing: tileSpacing, + tileExtent: tileExtent, + tileBuilder: (gridItem) { + final filter = gridItem.filter; + return MetaData( + metaData: ScalerMetadata(gridItem), + child: FilterChipGridDecorator>( + gridItem: gridItem, + extent: tileExtent, + child: CoveredFilterChip( + key: Key(filter.key), + filter: filter, + extent: tileExtent, + pinned: pinnedFilters.contains(filter), + onTap: onTap, + ), + ), + ); + }, + tileAnimationDelay: tileAnimationDelay, + child: _FilterSectionedContent( + appBar: appBar, + appBarHeightNotifier: _appBarHeightNotifier, + visibleSections: visibleSections, + sortFactor: sortFactor, + selectable: selectable, + emptyBuilder: emptyBuilder, + scrollController: PrimaryScrollController.of(context)!, + ), + ); + }), + ); }, ); return sectionedListLayoutProvider; @@ -269,16 +287,18 @@ class _FilterGridContent extends StatelessWidget { class _FilterSectionedContent extends StatefulWidget { final Widget appBar; final ValueNotifier appBarHeightNotifier; - final Map>> visibleFilterSections; + final Map>> visibleSections; final ChipSortFactor sortFactor; + final bool selectable; final Widget Function() emptyBuilder; final ScrollController scrollController; const _FilterSectionedContent({ required this.appBar, required this.appBarHeightNotifier, - required this.visibleFilterSections, + required this.visibleSections, required this.sortFactor, + required this.selectable, required this.emptyBuilder, required this.scrollController, }); @@ -293,7 +313,7 @@ class _FilterSectionedContentState extends State<_Fi @override ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; - Map>> get visibleFilterSections => widget.visibleFilterSections; + Map>> get visibleSections => widget.visibleSections; Widget Function() get emptyBuilder => widget.emptyBuilder; @@ -328,14 +348,23 @@ class _FilterSectionedContentState extends State<_Fi child: scrollView, ); - return scaler; + final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); + final selector = GridSelectionGestureDetector>( + selectable: isMainMode && widget.selectable, + items: visibleSections.values.expand((v) => v).toList(), + scrollController: scrollController, + appBarHeightNotifier: appBarHeightNotifier, + child: scaler, + ); + + return selector; } Future _checkInitHighlight() async { final highlightInfo = context.read(); final filter = highlightInfo.clear(); if (filter is T) { - final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter); + final gridItem = visibleSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter); if (gridItem != null) { await Future.delayed(Durations.highlightScrollInitDelay); highlightInfo.trackItem(gridItem, highlightItem: filter); @@ -367,19 +396,18 @@ class _FilterScaler extends StatelessWidget { extent: extent, spacing: tileSpacing, borderWidth: AvesFilterChip.outlineWidth, - borderRadius: DecoratedFilterChip.radius(extent), + borderRadius: CoveredFilterChip.radius(extent), color: Colors.grey.shade700, ), child: child, ), scaledBuilder: (item, extent) { final filter = item.filter; - return DecoratedFilterChip( + return CoveredFilterChip( filter: filter, extent: extent, thumbnailExtent: context.read().effectiveExtentMax, pinned: pinnedFilters.contains(filter), - highlightable: false, ); }, highlightItem: (item) => item.filter, diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 2fe5905e8..1a68b7a6f 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -1,38 +1,22 @@ -import 'dart:ui'; - -import 'package:aves/app_mode.dart'; -import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.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/collection/collection_page.dart'; -import 'package:aves/widgets/common/app_bar_subtitle.dart'; -import 'package:aves/widgets/common/app_bar_title.dart'; -import 'package:aves/widgets/common/basic/menu_row.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.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/app_bar.dart'; 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/search/search_button.dart'; -import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; class FilterNavigationPage extends StatelessWidget { final CollectionSource source; final String title; - final ChipSetActionDelegate chipSetActionDelegate; final ChipSortFactor sortFactor; final bool groupable, showHeaders; - final ChipActionDelegate chipActionDelegate; - final List Function(T filter) chipActionsBuilder; + final ChipSetActionDelegate actionDelegate; final Map>> filterSections; final Widget Function() emptyBuilder; @@ -43,114 +27,54 @@ class FilterNavigationPage extends StatelessWidget { required this.sortFactor, this.groupable = false, this.showHeaders = false, - required this.chipSetActionDelegate, - required this.chipActionDelegate, - required this.chipActionsBuilder, + required this.actionDelegate, required this.filterSections, required this.emptyBuilder, }) : super(key: key); @override Widget build(BuildContext context) { - final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); - return FilterGridPage( - key: const Key('filter-grid-page'), - appBar: SliverAppBar( - title: InteractiveAppBarTitle( - onTap: () => _goToSearch(context), - child: SourceStateAwareAppBarTitle( - title: Text(title), + return SelectionProvider>( + child: Builder( + builder: (context) => FilterGridPage( + key: const Key('filter-grid-page'), + appBar: FilterGridAppBar( source: source, + title: title, + actionDelegate: actionDelegate, + groupable: groupable, + isEmpty: filterSections.isEmpty, ), - ), - actions: _buildActions(context), - titleSpacing: 0, - floating: true, - ), - filterSections: filterSections, - sortFactor: sortFactor, - showHeaders: showHeaders, - queryNotifier: ValueNotifier(''), - emptyBuilder: () => ValueListenableBuilder( - valueListenable: source.stateNotifier, - builder: (context, sourceState, child) { - return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink(); - }, - ), - onTap: (filter) => Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: CollectionPage.routeName), - builder: (context) => CollectionPage( - collection: CollectionLens( - source: source, - filters: [filter], - ), + sections: filterSections, + sortFactor: sortFactor, + showHeaders: showHeaders, + selectable: true, + queryNotifier: ValueNotifier(''), + emptyBuilder: () => ValueListenableBuilder( + valueListenable: source.stateNotifier, + builder: (context, sourceState, child) { + return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink(); + }, ), + onTap: (filter) => _goToCollection(context, filter), ), ), - onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null, ); } - void _showMenu(BuildContext context, T filter, Offset? tapPosition) async { - final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox; - const touchArea = Size(40, 40); - final selectedAction = await showMenu( - context: context, - position: RelativeRect.fromRect((tapPosition ?? Offset.zero) & touchArea, Offset.zero & overlay.size), - items: chipActionsBuilder(filter) - .map((action) => PopupMenuItem( - value: action, - child: MenuRow(text: action.getText(context), icon: action.getIcon()), - )) - .toList(), - ); - if (selectedAction != null) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipActionDelegate.onActionSelected(context, filter, selectedAction)); - } - } - - List _buildActions(BuildContext context) { - return [ - CollectionSearchButton(source: source), - PopupMenuButton( - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - return [ - PopupMenuItem( - key: const Key('menu-sort'), - value: ChipSetAction.sort, - child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort), - ), - if (groupable) - PopupMenuItem( - value: ChipSetAction.group, - child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), - ), - PopupMenuItem( - value: ChipSetAction.stats, - child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats), - ), - ]; - }, - onSelected: (action) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action)); - }, - ), - ]; - } - - void _goToSearch(BuildContext context) { + void _goToCollection(BuildContext context, CollectionFilter filter) { Navigator.push( - context, - SearchPageRoute( - delegate: CollectionSearchDelegate( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + collection: CollectionLens( source: source, + filters: [filter], ), - )); + ), + ), + ); } static int compareFiltersByDate(FilterGridItem a, FilterGridItem b) { @@ -167,21 +91,23 @@ class FilterNavigationPage extends StatelessWidget { return a.filter.compareTo(b.filter); } - static Iterable> sort(ChipSortFactor sortFactor, CollectionSource source, Set filters) { - Iterable> toGridItem(CollectionSource source, Set filters) { - return filters.map((filter) => FilterGridItem( - filter, - source.recentEntry(filter), - )); + static List> sort(ChipSortFactor sortFactor, CollectionSource source, Set filters) { + List> toGridItem(CollectionSource source, Set filters) { + return filters + .map((filter) => FilterGridItem( + filter, + source.recentEntry(filter), + )) + .toList(); } - Iterable> allMapEntries = {}; + List> allMapEntries = []; switch (sortFactor) { case ChipSortFactor.name: - allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName); + allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName); break; case ChipSortFactor.date: - allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate); + allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate); break; case ChipSortFactor.count: final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter)))); diff --git a/lib/widgets/filter_grids/common/section_header.dart b/lib/widgets/filter_grids/common/section_header.dart index b0038fa60..4f83005ea 100644 --- a/lib/widgets/filter_grids/common/section_header.dart +++ b/lib/widgets/filter_grids/common/section_header.dart @@ -2,7 +2,7 @@ import 'package:aves/widgets/common/grid/header.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:flutter/material.dart'; -class FilterChipSectionHeader extends StatelessWidget { +class FilterChipSectionHeader extends StatelessWidget { final ChipSectionKey sectionKey; const FilterChipSectionHeader({ @@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return SectionHeader( + return SectionHeader( sectionKey: sectionKey, leading: sectionKey.leading, title: sectionKey.title, - selectable: false, ); } diff --git a/lib/widgets/filter_grids/common/section_layout.dart b/lib/widgets/filter_grids/common/section_layout.dart index c46571900..c945c1642 100644 --- a/lib/widgets/filter_grids/common/section_layout.dart +++ b/lib/widgets/filter_grids/common/section_layout.dart @@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider extends Sect @override Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) { - return FilterChipSectionHeader( + return FilterChipSectionHeader>( sectionKey: sectionKey as ChipSectionKey, ); } diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index 98039f9aa..e99a2f05a 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -1,4 +1,3 @@ -import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/settings/settings.dart'; @@ -8,8 +7,7 @@ import 'package:aves/model/source/location.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/country_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; @@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget { builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( - source: source, - title: context.l10n.countryPageTitle, - sortFactor: settings.countrySortFactor, - chipSetActionDelegate: CountryChipSetActionDelegate(), - chipActionDelegate: ChipActionDelegate(), - chipActionsBuilder: (filter) => [ - settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, - ChipAction.setCover, - ChipAction.hide, - ], - filterSections: _getCountryEntries(source), - emptyBuilder: () => EmptyContent( - icon: AIcons.location, - text: context.l10n.countryEmpty, - ), - ), + builder: (context, snapshot) { + final gridItems = _getGridItems(source); + return FilterNavigationPage( + source: source, + title: context.l10n.countryPageTitle, + sortFactor: settings.countrySortFactor, + actionDelegate: CountryChipSetActionDelegate(gridItems), + filterSections: _groupToSections(gridItems), + emptyBuilder: () => EmptyContent( + icon: AIcons.location, + text: context.l10n.countryEmpty, + ), + ); + }, ); }, ); } - Map>> _getCountryEntries(CollectionSource source) { + List> _getGridItems(CollectionSource source) { final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet(); - final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); - return _group(sorted); + return FilterNavigationPage.sort(settings.countrySortFactor, source, filters); } - static Map>> _group(Iterable> sortedMapEntries) { + static Map>> _groupToSections(Iterable> sortedMapEntries) { final pinned = settings.pinnedFilters.whereType(); final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 8fe3328a9..65114973c 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -1,4 +1,3 @@ -import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/settings.dart'; @@ -8,8 +7,7 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; -import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; +import 'package:aves/widgets/filter_grids/common/action_delegates/tag_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:collection/collection.dart'; @@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget { builder: (context, s, child) { return StreamBuilder( stream: source.eventBus.on(), - builder: (context, snapshot) => FilterNavigationPage( - source: source, - title: context.l10n.tagPageTitle, - sortFactor: settings.tagSortFactor, - chipSetActionDelegate: TagChipSetActionDelegate(), - chipActionDelegate: ChipActionDelegate(), - chipActionsBuilder: (filter) => [ - settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, - ChipAction.setCover, - ChipAction.hide, - ], - filterSections: _getTagEntries(source), - emptyBuilder: () => EmptyContent( - icon: AIcons.tag, - text: context.l10n.tagEmpty, - ), - ), + builder: (context, snapshot) { + final gridItems = _getGridItems(source); + return FilterNavigationPage( + source: source, + title: context.l10n.tagPageTitle, + sortFactor: settings.tagSortFactor, + actionDelegate: TagChipSetActionDelegate(gridItems), + filterSections: _groupToSections(gridItems), + emptyBuilder: () => EmptyContent( + icon: AIcons.tag, + text: context.l10n.tagEmpty, + ), + ); + }, ); }, ); } - Map>> _getTagEntries(CollectionSource source) { + List> _getGridItems(CollectionSource source) { final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet(); - final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); - return _group(sorted); + return FilterNavigationPage.sort(settings.tagSortFactor, source, filters); } - static Map>> _group(Iterable> sortedMapEntries) { + static Map>> _groupToSections(Iterable> sortedMapEntries) { final pinned = settings.pinnedFilters.whereType(); final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart index 95edb3557..9b4fa9701 100644 --- a/lib/widgets/search/search_button.dart +++ b/lib/widgets/search/search_button.dart @@ -26,12 +26,13 @@ class CollectionSearchButton extends StatelessWidget { void _goToSearch(BuildContext context) { Navigator.push( - context, - SearchPageRoute( - delegate: CollectionSearchDelegate( - source: source, - parentCollection: parentCollection, - ), - )); + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: source, + parentCollection: parentCollection, + ), + ), + ); } } diff --git a/lib/widgets/settings/privacy/hidden_filters.dart b/lib/widgets/settings/privacy/hidden_filters.dart index e5eff75da..619cf61b2 100644 --- a/lib/widgets/settings/privacy/hidden_filters.dart +++ b/lib/widgets/settings/privacy/hidden_filters.dart @@ -76,7 +76,7 @@ class HiddenFilterPage extends StatelessWidget { .map((filter) => AvesFilterChip( filter: filter, removable: true, - onTap: (filter) => context.read().changeFilterVisibility(filter, true), + onTap: (filter) => context.read().changeFilterVisibility({filter}, true), onLongPress: null, )) .toList(), diff --git a/lib/widgets/settings/video/video.dart b/lib/widgets/settings/video/video.dart index 4514d8ebb..52fa4b2b3 100644 --- a/lib/widgets/settings/video/video.dart +++ b/lib/widgets/settings/video/video.dart @@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget { if (!standalonePage) SwitchListTile( value: currentShowVideos, - onChanged: (v) => context.read().changeFilterVisibility(MimeFilter.video, v), + onChanged: (v) => context.read().changeFilterVisibility({MimeFilter.video}, v), title: Text(context.l10n.settingsVideoShowVideos), ), const VideoActionsTile(), diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index b0817a449..28163c161 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/model/multipage.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/collection/thumbnail/theme.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -91,7 +91,7 @@ class _MultiPageOverlayState extends State { final horizontalMargin = SizedBox(width: marginWidth); const separator = SizedBox(width: separatorWidth); - return ThumbnailTheme( + return GridTheme( extent: extent, showLocation: false, child: StreamBuilder( diff --git a/lib/widgets/viewer/overlay/bottom/panorama.dart b/lib/widgets/viewer/overlay/bottom/panorama.dart index 8ee5bdecc..78be3c16f 100644 --- a/lib/widgets/viewer/overlay/bottom/panorama.dart +++ b/lib/widgets/viewer/overlay/bottom/panorama.dart @@ -1,10 +1,10 @@ import 'package:aves/model/entry.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/utils/pedantic.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/panorama_page.dart'; import 'package:flutter/material.dart'; -import 'package:aves/utils/pedantic.dart'; class PanoramaOverlay extends StatelessWidget { final AvesEntry entry; diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart index 2a431fe1a..c04a205d3 100644 --- a/test_driver/app_test.dart +++ b/test_driver/app_test.dart @@ -130,7 +130,7 @@ void selectFirstAlbum() { await driver.tap(find.descendant( of: find.byValueKey('filter-grid-page'), - matching: find.byType('DecoratedFilterChip'), + matching: find.byType('CoveredFilterChip'), firstMatchOnly: true, )); await driver.waitUntilNoTransientCallbacks();