diff --git a/lib/model/selection.dart b/lib/model/selection.dart new file mode 100644 index 000000000..6f85cdcae --- /dev/null +++ b/lib/model/selection.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; + +class Selection extends ChangeNotifier { + bool _isSelecting = false; + + bool get isSelecting => _isSelecting; + + final Set _selection = {}; + + Set get selection => _selection; + + void browse() { + clearSelection(); + _isSelecting = false; + notifyListeners(); + } + + void select() { + _isSelecting = true; + notifyListeners(); + } + + bool isSelected(Iterable items) => items.every(selection.contains); + + void addToSelection(Iterable items) { + _selection.addAll(items); + notifyListeners(); + } + + void removeFromSelection(Iterable items) { + _selection.removeAll(items); + notifyListeners(); + } + + void clearSelection() { + _selection.clear(); + notifyListeners(); + } + + void toggleSelection(T item) { + if (_selection.isEmpty) select(); + if (!_selection.remove(item)) _selection.add(item); + notifyListeners(); + } +} diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 3bf3b6a2d..b18292a5e 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -18,7 +18,7 @@ import 'package:flutter/foundation.dart'; import 'enums.dart'; -class CollectionLens with ChangeNotifier, CollectionActivityMixin { +class CollectionLens with ChangeNotifier { final CollectionSource source; final Set filters; EntryGroupFactor groupFactor; @@ -213,55 +213,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin { _sortedEntries?.removeWhere(entries.contains); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty))); - selection.removeAll(entries); notifyListeners(); } } - -mixin CollectionActivityMixin { - final ValueNotifier _activityNotifier = ValueNotifier(Activity.browse); - - ValueNotifier get activityNotifier => _activityNotifier; - - bool get isBrowsing => _activityNotifier.value == Activity.browse; - - bool get isSelecting => _activityNotifier.value == Activity.select; - - void browse() { - clearSelection(); - _activityNotifier.value = Activity.browse; - } - - void select() => _activityNotifier.value = Activity.select; - - // selection - - final AChangeNotifier selectionChangeNotifier = AChangeNotifier(); - - final Set _selection = {}; - - Set get selection => _selection; - - bool isSelected(Iterable entries) => entries.every(selection.contains); - - void addToSelection(Iterable entries) { - _selection.addAll(entries); - selectionChangeNotifier.notifyListeners(); - } - - void removeFromSelection(Iterable entries) { - _selection.removeAll(entries); - selectionChangeNotifier.notifyListeners(); - } - - void clearSelection() { - _selection.clear(); - selectionChangeNotifier.notifyListeners(); - } - - void toggleSelection(AvesEntry entry) { - if (_selection.isEmpty) select(); - if (!_selection.remove(entry)) _selection.add(entry); - selectionChangeNotifier.notifyListeners(); - } -} diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index 3721deeeb..228310df4 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -1,5 +1,3 @@ -enum Activity { browse, select } - enum SourceState { loading, cataloguing, locating, ready } enum ChipSortFactor { date, name, count } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 8f4f4a078..78c78838f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -5,6 +5,7 @@ import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/entry_actions.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_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -49,6 +50,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); late AnimationController _browseToSelectAnimation; late Future _canAddShortcutsLoader; + final ValueNotifier _isSelectingNotifier = ValueNotifier(false); CollectionLens get collection => widget.collection; @@ -63,6 +65,7 @@ class _CollectionAppBarState extends State with SingleTickerPr duration: Durations.iconAnimation, vsync: this, ); + _isSelectingNotifier.addListener(_onActivityChange); _canAddShortcutsLoader = AppShortcutService.canPin(); _registerWidget(widget); WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight()); @@ -78,35 +81,35 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void dispose() { _unregisterWidget(widget); + _isSelectingNotifier.removeListener(_onActivityChange); _browseToSelectAnimation.dispose(); _searchFieldController.dispose(); super.dispose(); } void _registerWidget(CollectionAppBar widget) { - widget.collection.activityNotifier.addListener(_onActivityChange); widget.collection.filterChangeNotifier.addListener(_updateHeight); } void _unregisterWidget(CollectionAppBar widget) { - widget.collection.activityNotifier.removeListener(_onActivityChange); widget.collection.filterChangeNotifier.removeListener(_updateHeight); } @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return ValueListenableBuilder( - valueListenable: collection.activityNotifier, - builder: (context, activity, child) { + return Selector, bool>( + selector: (context, selection) => selection.isSelecting, + builder: (context, isSelecting, child) { + _isSelectingNotifier.value = isSelecting; return AnimatedBuilder( animation: collection.filterChangeNotifier, builder: (context, child) { final removableFilters = appMode != AppMode.pickInternal; return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading() : null, - title: _buildAppBarTitle(), - actions: _buildActions(), + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: _buildAppBarTitle(isSelecting), + actions: _buildActions(isSelecting), bottom: hasFilters ? FilterBar( filters: collection.filters, @@ -123,15 +126,15 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } - Widget _buildAppBarLeading() { + Widget _buildAppBarLeading(bool isSelecting) { VoidCallback? onPressed; String? tooltip; - if (collection.isBrowsing) { + if (isSelecting) { + onPressed = () => context.read>().browse(); + tooltip = MaterialLocalizations.of(context).backButtonTooltip; + } else { onPressed = Scaffold.of(context).openDrawer; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; - } else if (collection.isSelecting) { - onPressed = collection.browse; - tooltip = MaterialLocalizations.of(context).backButtonTooltip; } return IconButton( key: const Key('appbar-leading-button'), @@ -144,8 +147,13 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } - Widget? _buildAppBarTitle() { - if (collection.isBrowsing) { + 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; Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); if (appMode == AppMode.main) { @@ -158,36 +166,25 @@ class _CollectionAppBarState extends State with SingleTickerPr onTap: appMode.canSearch ? _goToSearch : null, child: title, ); - } else if (collection.isSelecting) { - return AnimatedBuilder( - animation: collection.selectionChangeNotifier, - builder: (context, child) { - final count = collection.selection.length; - return Text(context.l10n.collectionSelectionPageTitle(count)); - }, - ); } - return null; } - List _buildActions() { + List _buildActions(bool isSelecting) { final appMode = context.watch>().value; return [ - if (collection.isBrowsing && appMode.canSearch) + if (!isSelecting && appMode.canSearch) CollectionSearchButton( source: source, parentCollection: collection, ), - if (collection.isSelecting) - ...EntryActions.selection.map((action) => AnimatedBuilder( - animation: collection.selectionChangeNotifier, - builder: (context, child) { - return IconButton( - icon: Icon(action.getIcon()), - onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), - tooltip: action.getText(context), - ); - }, + if (isSelecting) + ...EntryActions.selection.map((action) => Selector, bool>( + selector: (context, selection) => selection.selection.isEmpty, + builder: (context, isEmpty, child) => IconButton( + icon: Icon(action.getIcon()), + onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), + tooltip: action.getText(context), + ), )), FutureBuilder( future: _canAddShortcutsLoader, @@ -196,8 +193,9 @@ class _CollectionAppBarState extends State with SingleTickerPr return PopupMenuButton( key: const Key('appbar-menu-button'), itemBuilder: (context) { + final selection = context.read>(); final isNotEmpty = !collection.isEmpty; - final hasSelection = collection.selection.isNotEmpty; + final hasSelection = selection.selection.isNotEmpty; return [ PopupMenuItem( key: const Key('menu-sort'), @@ -210,7 +208,7 @@ class _CollectionAppBarState extends State with SingleTickerPr value: CollectionAction.group, child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), ), - if (collection.isBrowsing && appMode == AppMode.main) ...[ + if (!selection.isSelecting && appMode == AppMode.main) ...[ PopupMenuItem( value: CollectionAction.select, enabled: isNotEmpty, @@ -227,7 +225,7 @@ class _CollectionAppBarState extends State with SingleTickerPr child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut), ), ], - if (collection.isSelecting) ...[ + if (selection.isSelecting) ...[ const PopupMenuDivider(), PopupMenuItem( value: CollectionAction.copy, @@ -247,7 +245,7 @@ class _CollectionAppBarState extends State with SingleTickerPr const PopupMenuDivider(), PopupMenuItem( value: CollectionAction.selectAll, - enabled: collection.selection.length < collection.entryCount, + enabled: selection.selection.length < collection.entryCount, child: MenuRow(text: context.l10n.collectionActionSelectAll), ), PopupMenuItem( @@ -269,7 +267,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } void _onActivityChange() { - if (collection.isSelecting) { + if (context.read>().isSelecting) { _browseToSelectAnimation.forward(); } else { _browseToSelectAnimation.reverse(); @@ -289,13 +287,13 @@ class _CollectionAppBarState extends State with SingleTickerPr _actionDelegate.onCollectionActionSelected(context, action); break; case CollectionAction.select: - collection.select(); + context.read>().select(); break; case CollectionAction.selectAll: - collection.addToSelection(collection.sortedEntries); + context.read>().addToSelection(collection.sortedEntries); break; case CollectionAction.selectNone: - collection.clearSelection(); + context.read>().clearSelection(); break; case CollectionAction.stats: _goToStats(); diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 88f40a028..273f8fc32 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -12,7 +12,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/draggable_thumb_label.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; -import 'package:aves/widgets/collection/grid/selector.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'; @@ -22,6 +21,7 @@ import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; 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/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; @@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector( selectable: isMainMode, - collection: collection, + entries: collection.sortedEntries, scrollController: scrollController, appBarHeightNotifier: appBarHeightNotifier, child: scaler, diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 5cfb43a81..f4ad0c5cc 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,8 +1,11 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -35,22 +38,27 @@ class _CollectionPageState extends State { Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: WillPopScope( - onWillPop: () { - if (collection.isSelecting) { - collection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - key: Key('collection-grid'), + body: SelectionProvider( + child: Builder( + builder: (context) => WillPopScope( + onWillPop: () { + final selection = context.read>(); + if (selection.isSelecting) { + selection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + key: Key('collection-grid'), + ), + ), ), ), ), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index fac94a157..eb6ab71e3 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_op_events.dart'; @@ -31,8 +33,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _showDeleteDialog(context); break; case EntryAction.share: - final collection = context.read(); - AndroidAppService.shareEntries(collection.selection).then((success) { + final selection = context.read>().selection; + AndroidAppService.shareEntries(selection).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; @@ -59,16 +61,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware void _refreshMetadata(BuildContext context) { final collection = context.read(); - collection.source.refreshMetadata(collection.selection); - collection.browse(); + final selection = context.read>(); + collection.source.refreshMetadata(selection.selection); + selection.browse(); } Future _moveSelection(BuildContext context, {required MoveType moveType}) async { final collection = context.read(); final source = collection.source; - final selection = collection.selection; + final selection = context.read>(); + final selectedItems = selection.selection; - final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet(); + final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); if (moveType == MoveType.move) { // check whether moving is possible given OS restrictions, // before asking to pick a destination album @@ -95,11 +99,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return; - if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return; + if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; // do not directly use selection when moving and post-processing items // as source monitoring may remove obsolete items from the original selection - final todoEntries = selection.toSet(); + final todoEntries = selectedItems.toSet(); final copy = moveType == MoveType.copy; final todoCount = todoEntries.length; @@ -118,7 +122,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware destinationAlbum: destinationAlbum, movedOps: movedOps, ); - collection.browse(); + selection.browse(); source.resumeMonitoring(); // cleanup @@ -177,9 +181,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Future _showDeleteDialog(BuildContext context) async { final collection = context.read(); final source = collection.source; - final selection = collection.selection; - final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet(); - final todoCount = selection.length; + final selection = context.read>(); + final selectedItems = selection.selection; + final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = selectedItems.length; final confirmed = await showDialog( context: context, @@ -207,12 +212,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware source.pauseMonitoring(); showOpReport( context: context, - opStream: imageFileService.delete(selection), + opStream: imageFileService.delete(selectedItems), itemCount: todoCount, onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); - collection.browse(); + selection.browse(); source.resumeMonitoring(); final deletedCount = deletedUris.length; diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 79e816b0c..e9345cf59 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -1,5 +1,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; @@ -31,10 +32,11 @@ class InteractiveThumbnail extends StatelessWidget { final appMode = context.read>().value; switch (appMode) { case AppMode.main: - if (collection.isBrowsing) { + final selection = context.read>(); + if (selection.isSelecting) { + selection.toggleSelection(entry); + } else { _goToViewer(context); - } else if (collection.isSelecting) { - collection.toggleSelection(entry); } break; case AppMode.pickExternal: diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 4da8d33f3..9ecbc4895 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -2,8 +2,7 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/highlight.dart'; -import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/enums.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'; @@ -59,47 +58,41 @@ class ThumbnailSelectionOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - final collection = context.watch(); - return ValueListenableBuilder( - valueListenable: collection.activityNotifier, - builder: (context, activity, child) { - final child = collection.isSelecting - ? AnimatedBuilder( - animation: collection.selectionChangeNotifier, - builder: (context, child) { - final selected = collection.isSelected([entry]); - var child = collection.isSelecting - ? OverlayIcon( - key: ValueKey(selected), - icon: selected ? 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: selected ? Colors.black54 : Colors.transparent, - child: child, - ); - return child; - }, - ) - : const SizedBox.shrink(); - return AnimatedSwitcher( - duration: duration, - child: child, - ); - }, + 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, ); } } diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index b6205baf6..443614298 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -1,5 +1,6 @@ +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/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; @@ -79,11 +80,12 @@ class SectionHeader extends StatelessWidget { void _toggleSectionSelection(BuildContext context) { final collection = context.read(); final sectionEntries = collection.sections[sectionKey]!; - final selected = collection.isSelected(sectionEntries); - if (selected) { - collection.removeFromSelection(sectionEntries); + final selection = context.read>(); + final isSelected = selection.isSelected(sectionEntries); + if (isSelected) { + selection.removeFromSelection(sectionEntries); } else { - collection.addToSelection(sectionEntries); + selection.addToSelection(sectionEntries); } } @@ -142,72 +144,82 @@ class _SectionSelectableLeading extends StatelessWidget { Widget build(BuildContext context) { if (!selectable) return _buildBrowsing(context); - final collection = context.watch(); - return ValueListenableBuilder( - valueListenable: collection.activityNotifier, - builder: (context, activity, child) { - final child = collection.isSelecting - ? AnimatedBuilder( - animation: collection.selectionChangeNotifier, - builder: (context, child) { - final sectionEntries = collection.sections[sectionKey]!; - final selected = collection.isSelected(sectionEntries); - final child = TooltipTheme( - key: ValueKey(selected), - data: TooltipTheme.of(context).copyWith( - preferBelow: false, - ), - child: IconButton( - iconSize: 26, - padding: const EdgeInsets.only(top: 1), - alignment: AlignmentDirectional.topStart, - icon: Icon(selected ? AIcons.selected : AIcons.unselected), - onPressed: onPressed, - tooltip: selected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip, - constraints: const BoxConstraints( - minHeight: leadingDimension, - minWidth: leadingDimension, - ), - ), - ); - return AnimatedSwitcher( - duration: Durations.sectionHeaderAnimation, - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeOutBack, - transitionBuilder: (child, animation) => ScaleTransition( - scale: animation, - child: child, - ), - child: child, - ); - }, - ) - : _buildBrowsing(context); - return AnimatedSwitcher( - duration: Durations.sectionHeaderAnimation, - switchInCurve: Curves.easeInOut, - switchOutCurve: Curves.easeInOut, - transitionBuilder: (child, animation) { - Widget transition = ScaleTransition( - scale: animation, - child: child, - ); - if (browsingBuilder == null) { - // when switching with a header that has no icon, - // we also transition the size for a smooth push to the text - transition = SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - child: transition, - ); - } - return transition; - }, + final isSelecting = context.select, bool>((selection) => selection.isSelecting); + final Widget child = isSelecting + ? _SectionSelectingLeading( + sectionKey: sectionKey, + onPressed: onPressed, + ) + : _buildBrowsing(context); + + return AnimatedSwitcher( + duration: Durations.sectionHeaderAnimation, + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + transitionBuilder: (child, animation) { + Widget transition = ScaleTransition( + scale: animation, child: child, ); + if (browsingBuilder == null) { + // when switching with a header that has no icon, + // we also transition the size for a smooth push to the text + transition = SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: transition, + ); + } + return transition; }, + child: child, ); } Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension); } + +class _SectionSelectingLeading extends StatelessWidget { + final SectionKey sectionKey; + final VoidCallback? onPressed; + + const _SectionSelectingLeading({ + Key? key, + required this.sectionKey, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final sectionEntries = context.watch().sections[sectionKey]!; + final selection = context.watch>(); + final isSelected = selection.isSelected(sectionEntries); + return AnimatedSwitcher( + duration: Durations.sectionHeaderAnimation, + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeOutBack, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: child, + ), + child: TooltipTheme( + key: ValueKey(isSelected), + data: TooltipTheme.of(context).copyWith( + preferBelow: false, + ), + child: IconButton( + iconSize: 26, + padding: const EdgeInsets.only(top: 1), + alignment: AlignmentDirectional.topStart, + icon: Icon(isSelected ? AIcons.selected : AIcons.unselected), + onPressed: onPressed, + tooltip: isSelected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip, + constraints: const BoxConstraints( + minHeight: SectionHeader.leadingDimension, + minWidth: SectionHeader.leadingDimension, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/collection/grid/selector.dart b/lib/widgets/common/grid/selector.dart similarity index 79% rename from lib/widgets/collection/grid/selector.dart rename to lib/widgets/common/grid/selector.dart index de7f65b02..4a85d32d1 100644 --- a/lib/widgets/collection/grid/selector.dart +++ b/lib/widgets/common/grid/selector.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'dart:math'; -import 'package:aves/model/entry.dart'; -import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; @@ -10,9 +9,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; -class GridSelectionGestureDetector extends StatefulWidget { +class GridSelectionGestureDetector extends StatefulWidget { final bool selectable; - final CollectionLens collection; + final List entries; final ScrollController scrollController; final ValueNotifier appBarHeightNotifier; final Widget child; @@ -20,17 +19,17 @@ class GridSelectionGestureDetector extends StatefulWidget { const GridSelectionGestureDetector({ Key? key, this.selectable = true, - required this.collection, + required this.entries, required this.scrollController, required this.appBarHeightNotifier, required this.child, }) : super(key: key); @override - _GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState(); + _GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState(); } -class _GridSelectionGestureDetectorState extends State { +class _GridSelectionGestureDetectorState extends State> { bool _pressing = false, _selecting = false; late int _fromIndex, _lastToIndex; late Offset _localPosition; @@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State widget.collection; - - List get entries => collection.sortedEntries; + List get entries => widget.entries; ScrollController get scrollController => widget.scrollController; @@ -58,8 +55,9 @@ class _GridSelectionGestureDetectorState extends State>(); + selection.toggleSelection(fromEntry); + _selecting = selection.isSelected([fromEntry]); _fromIndex = entries.indexOf(fromEntry); _lastToIndex = _fromIndex; _scrollableInsets = EdgeInsets.only( @@ -134,41 +132,42 @@ class _GridSelectionGestureDetectorState extends State>(); + final sectionedListLayout = context.read>(); return sectionedListLayout.getItemAt(offset); } void _toggleSelectionToIndex(int toIndex) { if (toIndex == -1) return; + final selection = context.read>(); if (_selecting) { if (toIndex <= _fromIndex) { if (toIndex < _lastToIndex) { - collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); + selection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); if (_fromIndex < _lastToIndex) { - collection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1)); + selection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1)); } } else if (_lastToIndex < toIndex) { - collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); + selection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); } } else if (_fromIndex < toIndex) { if (_lastToIndex < toIndex) { - collection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1)); + selection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1)); if (_lastToIndex < _fromIndex) { - collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); + selection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); } } else if (toIndex < _lastToIndex) { - collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); + selection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); } } _lastToIndex = toIndex; } else { - collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1)); + selection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1)); } } } diff --git a/lib/widgets/common/providers/selection_provider.dart b/lib/widgets/common/providers/selection_provider.dart new file mode 100644 index 000000000..5d44029cf --- /dev/null +++ b/lib/widgets/common/providers/selection_provider.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/selection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class SelectionProvider extends StatelessWidget { + final Widget child; + + const SelectionProvider({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider>( + create: (context) => Selection(), + child: child, + ); + } +} diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart index f4cd6a1d7..0c2c93709 100644 --- a/lib/widgets/dialogs/item_pick_dialog.dart +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -1,9 +1,11 @@ import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -36,13 +38,15 @@ class _ItemPickDialogState extends State { value: ValueNotifier(AppMode.pickInternal), child: MediaQueryDataProvider( child: Scaffold( - body: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - settingsRouteKey: CollectionPage.routeName, + body: SelectionProvider( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + settingsRouteKey: CollectionPage.routeName, + ), ), ), ),