multi selection generalization prep

This commit is contained in:
Thibault Deckers 2021-07-09 18:31:26 +09:00
parent 981fd41e27
commit 81ab903d39
13 changed files with 303 additions and 268 deletions

45
lib/model/selection.dart Normal file
View file

@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
class Selection<T> extends ChangeNotifier {
bool _isSelecting = false;
bool get isSelecting => _isSelecting;
final Set<T> _selection = {};
Set<T> get selection => _selection;
void browse() {
clearSelection();
_isSelecting = false;
notifyListeners();
}
void select() {
_isSelecting = true;
notifyListeners();
}
bool isSelected(Iterable<T> items) => items.every(selection.contains);
void addToSelection(Iterable<T> items) {
_selection.addAll(items);
notifyListeners();
}
void removeFromSelection(Iterable<T> 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();
}
}

View file

@ -18,7 +18,7 @@ import 'package:flutter/foundation.dart';
import 'enums.dart'; import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin { class CollectionLens with ChangeNotifier {
final CollectionSource source; final CollectionSource source;
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor; EntryGroupFactor groupFactor;
@ -213,55 +213,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
_sortedEntries?.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty))); sections = Map.unmodifiable(Map.fromEntries(sections.entries.where((kv) => kv.value.isNotEmpty)));
selection.removeAll(entries);
notifyListeners(); notifyListeners();
} }
} }
mixin CollectionActivityMixin {
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);
ValueNotifier<Activity> 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<AvesEntry> _selection = {};
Set<AvesEntry> get selection => _selection;
bool isSelected(Iterable<AvesEntry> entries) => entries.every(selection.contains);
void addToSelection(Iterable<AvesEntry> entries) {
_selection.addAll(entries);
selectionChangeNotifier.notifyListeners();
}
void removeFromSelection(Iterable<AvesEntry> 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();
}
}

View file

@ -1,5 +1,3 @@
enum Activity { browse, select }
enum SourceState { loading, cataloguing, locating, ready } enum SourceState { loading, cataloguing, locating, ready }
enum ChipSortFactor { date, name, count } enum ChipSortFactor { date, name, count }

View file

@ -5,6 +5,7 @@ import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
@ -49,6 +50,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader; late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@ -63,6 +65,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
duration: Durations.iconAnimation, duration: Durations.iconAnimation,
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AppShortcutService.canPin(); _canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight()); WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
@ -78,35 +81,35 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void dispose() { void dispose() {
_unregisterWidget(widget); _unregisterWidget(widget);
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose(); _browseToSelectAnimation.dispose();
_searchFieldController.dispose(); _searchFieldController.dispose();
super.dispose(); super.dispose();
} }
void _registerWidget(CollectionAppBar widget) { void _registerWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.addListener(_onActivityChange);
widget.collection.filterChangeNotifier.addListener(_updateHeight); widget.collection.filterChangeNotifier.addListener(_updateHeight);
} }
void _unregisterWidget(CollectionAppBar widget) { void _unregisterWidget(CollectionAppBar widget) {
widget.collection.activityNotifier.removeListener(_onActivityChange);
widget.collection.filterChangeNotifier.removeListener(_updateHeight); widget.collection.filterChangeNotifier.removeListener(_updateHeight);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return ValueListenableBuilder<Activity>( return Selector<Selection<AvesEntry>, bool>(
valueListenable: collection.activityNotifier, selector: (context, selection) => selection.isSelecting,
builder: (context, activity, child) { builder: (context, isSelecting, child) {
_isSelectingNotifier.value = isSelecting;
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
builder: (context, child) { builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal; final removableFilters = appMode != AppMode.pickInternal;
return SliverAppBar( return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading() : null, leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(), title: _buildAppBarTitle(isSelecting),
actions: _buildActions(), actions: _buildActions(isSelecting),
bottom: hasFilters bottom: hasFilters
? FilterBar( ? FilterBar(
filters: collection.filters, filters: collection.filters,
@ -123,15 +126,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget _buildAppBarLeading() { Widget _buildAppBarLeading(bool isSelecting) {
VoidCallback? onPressed; VoidCallback? onPressed;
String? tooltip; String? tooltip;
if (collection.isBrowsing) { if (isSelecting) {
onPressed = () => context.read<Selection<AvesEntry>>().browse();
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} else {
onPressed = Scaffold.of(context).openDrawer; onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip; tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
} else if (collection.isSelecting) {
onPressed = collection.browse;
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} }
return IconButton( return IconButton(
key: const Key('appbar-leading-button'), key: const Key('appbar-leading-button'),
@ -144,8 +147,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
Widget? _buildAppBarTitle() { Widget? _buildAppBarTitle(bool isSelecting) {
if (collection.isBrowsing) { if (isSelecting) {
return Selector<Selection<AvesEntry>, int>(
selector: (context, selection) => selection.selection.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
if (appMode == AppMode.main) { if (appMode == AppMode.main) {
@ -158,36 +166,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
onTap: appMode.canSearch ? _goToSearch : null, onTap: appMode.canSearch ? _goToSearch : null,
child: title, 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<Widget> _buildActions() { List<Widget> _buildActions(bool isSelecting) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return [ return [
if (collection.isBrowsing && appMode.canSearch) if (!isSelecting && appMode.canSearch)
CollectionSearchButton( CollectionSearchButton(
source: source, source: source,
parentCollection: collection, parentCollection: collection,
), ),
if (collection.isSelecting) if (isSelecting)
...EntryActions.selection.map((action) => AnimatedBuilder( ...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
animation: collection.selectionChangeNotifier, selector: (context, selection) => selection.selection.isEmpty,
builder: (context, child) { builder: (context, isEmpty, child) => IconButton(
return IconButton(
icon: Icon(action.getIcon()), icon: Icon(action.getIcon()),
onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
tooltip: action.getText(context), tooltip: action.getText(context),
); ),
},
)), )),
FutureBuilder<bool>( FutureBuilder<bool>(
future: _canAddShortcutsLoader, future: _canAddShortcutsLoader,
@ -196,8 +193,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return PopupMenuButton<CollectionAction>( return PopupMenuButton<CollectionAction>(
key: const Key('appbar-menu-button'), key: const Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
final selection = context.read<Selection<AvesEntry>>();
final isNotEmpty = !collection.isEmpty; final isNotEmpty = !collection.isEmpty;
final hasSelection = collection.selection.isNotEmpty; final hasSelection = selection.selection.isNotEmpty;
return [ return [
PopupMenuItem( PopupMenuItem(
key: const Key('menu-sort'), key: const Key('menu-sort'),
@ -210,7 +208,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.group, value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
), ),
if (collection.isBrowsing && appMode == AppMode.main) ...[ if (!selection.isSelecting && appMode == AppMode.main) ...[
PopupMenuItem( PopupMenuItem(
value: CollectionAction.select, value: CollectionAction.select,
enabled: isNotEmpty, enabled: isNotEmpty,
@ -227,7 +225,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut), child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
), ),
], ],
if (collection.isSelecting) ...[ if (selection.isSelecting) ...[
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.copy, value: CollectionAction.copy,
@ -247,7 +245,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.selectAll, value: CollectionAction.selectAll,
enabled: collection.selection.length < collection.entryCount, enabled: selection.selection.length < collection.entryCount,
child: MenuRow(text: context.l10n.collectionActionSelectAll), child: MenuRow(text: context.l10n.collectionActionSelectAll),
), ),
PopupMenuItem( PopupMenuItem(
@ -269,7 +267,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
void _onActivityChange() { void _onActivityChange() {
if (collection.isSelecting) { if (context.read<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward(); _browseToSelectAnimation.forward();
} else { } else {
_browseToSelectAnimation.reverse(); _browseToSelectAnimation.reverse();
@ -289,13 +287,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_actionDelegate.onCollectionActionSelected(context, action); _actionDelegate.onCollectionActionSelected(context, action);
break; break;
case CollectionAction.select: case CollectionAction.select:
collection.select(); context.read<Selection<AvesEntry>>().select();
break; break;
case CollectionAction.selectAll: case CollectionAction.selectAll:
collection.addToSelection(collection.sortedEntries); context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
break; break;
case CollectionAction.selectNone: case CollectionAction.selectNone:
collection.clearSelection(); context.read<Selection<AvesEntry>>().clearSelection();
break; break;
case CollectionAction.stats: case CollectionAction.stats:
_goToStats(); _goToStats();

View file

@ -12,7 +12,6 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/draggable_thumb_label.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/section_layout.dart';
import 'package:aves/widgets/collection/grid/selector.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.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/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.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/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/sliver.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main); final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector( final selector = GridSelectionGestureDetector(
selectable: isMainMode, selectable: isMainMode,
collection: collection, entries: collection.sortedEntries,
scrollController: scrollController, scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scaler, child: scaler,

View file

@ -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/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.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/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -35,10 +38,13 @@ class _CollectionPageState extends State<CollectionPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: WillPopScope( body: SelectionProvider<AvesEntry>(
child: Builder(
builder: (context) => WillPopScope(
onWillPop: () { onWillPop: () {
if (collection.isSelecting) { final selection = context.read<Selection<AvesEntry>>();
collection.browse(); if (selection.isSelecting) {
selection.browse();
return SynchronousFuture(false); return SynchronousFuture(false);
} }
return SynchronousFuture(true); return SynchronousFuture(true);
@ -57,6 +63,8 @@ class _CollectionPageState extends State<CollectionPage> {
), ),
), ),
), ),
),
),
drawer: const AppDrawer(), drawer: const AppDrawer(),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
), ),

View file

@ -3,8 +3,10 @@ import 'dart:async';
import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.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/filters/album.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
@ -31,8 +33,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context); _showDeleteDialog(context);
break; break;
case EntryAction.share: case EntryAction.share:
final collection = context.read<CollectionLens>(); final selection = context.read<Selection<AvesEntry>>().selection;
AndroidAppService.shareEntries(collection.selection).then((success) { AndroidAppService.shareEntries(selection).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;
@ -59,16 +61,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
void _refreshMetadata(BuildContext context) { void _refreshMetadata(BuildContext context) {
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
collection.source.refreshMetadata(collection.selection); final selection = context.read<Selection<AvesEntry>>();
collection.browse(); collection.source.refreshMetadata(selection.selection);
selection.browse();
} }
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async { Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final source = collection.source; final source = collection.source;
final selection = collection.selection; final selection = context.read<Selection<AvesEntry>>();
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) { if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions, // check whether moving is possible given OS restrictions,
// before asking to pick a destination album // 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 (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 // do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection // 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 copy = moveType == MoveType.copy;
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
@ -118,7 +122,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
movedOps: movedOps, movedOps: movedOps,
); );
collection.browse(); selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
// cleanup // cleanup
@ -177,9 +181,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
Future<void> _showDeleteDialog(BuildContext context) async { Future<void> _showDeleteDialog(BuildContext context) async {
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final source = collection.source; final source = collection.source;
final selection = collection.selection; final selection = context.read<Selection<AvesEntry>>();
final selectionDirs = selection.map((e) => e.directory).whereNotNull().toSet(); final selectedItems = selection.selection;
final todoCount = selection.length; final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@ -207,12 +212,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: imageFileService.delete(selection), opStream: imageFileService.delete(selectedItems),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
await source.removeEntries(deletedUris); await source.removeEntries(deletedUris);
collection.browse(); selection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
final deletedCount = deletedUris.length; final deletedCount = deletedUris.length;

View file

@ -1,5 +1,6 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; 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/collection_lens.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
@ -31,10 +32,11 @@ class InteractiveThumbnail extends StatelessWidget {
final appMode = context.read<ValueNotifier<AppMode>>().value; final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) { switch (appMode) {
case AppMode.main: case AppMode.main:
if (collection.isBrowsing) { final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
selection.toggleSelection(entry);
} else {
_goToViewer(context); _goToViewer(context);
} else if (collection.isSelecting) {
collection.toggleSelection(entry);
} }
break; break;
case AppMode.pickExternal: case AppMode.pickExternal:

View file

@ -2,8 +2,7 @@ import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/collection/thumbnail/theme.dart';
@ -59,19 +58,15 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final collection = context.watch<CollectionLens>(); final isSelecting = context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
return ValueListenableBuilder<Activity>( final child = isSelecting
valueListenable: collection.activityNotifier, ? Selector<Selection<AvesEntry>, bool>(
builder: (context, activity, child) { selector: (context, selection) => selection.isSelected([entry]),
final child = collection.isSelecting builder: (context, isSelected, child) {
? AnimatedBuilder( var child = isSelecting
animation: collection.selectionChangeNotifier,
builder: (context, child) {
final selected = collection.isSelected([entry]);
var child = collection.isSelecting
? OverlayIcon( ? OverlayIcon(
key: ValueKey(selected), key: ValueKey(isSelected),
icon: selected ? AIcons.selected : AIcons.unselected, icon: isSelected ? AIcons.selected : AIcons.unselected,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
) )
: const SizedBox.shrink(); : const SizedBox.shrink();
@ -88,7 +83,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
child = AnimatedContainer( child = AnimatedContainer(
duration: duration, duration: duration,
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
color: selected ? Colors.black54 : Colors.transparent, color: isSelected ? Colors.black54 : Colors.transparent,
child: child, child: child,
); );
return child; return child;
@ -99,8 +94,6 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
duration: duration, duration: duration,
child: child, child: child,
); );
},
);
} }
} }

View file

@ -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/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -79,11 +80,12 @@ class SectionHeader extends StatelessWidget {
void _toggleSectionSelection(BuildContext context) { void _toggleSectionSelection(BuildContext context) {
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final sectionEntries = collection.sections[sectionKey]!; final sectionEntries = collection.sections[sectionKey]!;
final selected = collection.isSelected(sectionEntries); final selection = context.read<Selection<AvesEntry>>();
if (selected) { final isSelected = selection.isSelected(sectionEntries);
collection.removeFromSelection(sectionEntries); if (isSelected) {
selection.removeFromSelection(sectionEntries);
} else { } else {
collection.addToSelection(sectionEntries); selection.addToSelection(sectionEntries);
} }
} }
@ -142,47 +144,14 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(context); if (!selectable) return _buildBrowsing(context);
final collection = context.watch<CollectionLens>(); final isSelecting = context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
return ValueListenableBuilder<Activity>( final Widget child = isSelecting
valueListenable: collection.activityNotifier, ? _SectionSelectingLeading(
builder: (context, activity, child) { sectionKey: sectionKey,
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, 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); : _buildBrowsing(context);
return AnimatedSwitcher( return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation, duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut, switchInCurve: Curves.easeInOut,
@ -205,9 +174,52 @@ class _SectionSelectableLeading extends StatelessWidget {
}, },
child: child, child: child,
); );
},
);
} }
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension); 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<CollectionLens>().sections[sectionKey]!;
final selection = context.watch<Selection<AvesEntry>>();
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,
),
),
),
);
}
}

View file

@ -1,8 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.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:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class GridSelectionGestureDetector extends StatefulWidget { class GridSelectionGestureDetector<T> extends StatefulWidget {
final bool selectable; final bool selectable;
final CollectionLens collection; final List<T> entries;
final ScrollController scrollController; final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Widget child; final Widget child;
@ -20,17 +19,17 @@ class GridSelectionGestureDetector extends StatefulWidget {
const GridSelectionGestureDetector({ const GridSelectionGestureDetector({
Key? key, Key? key,
this.selectable = true, this.selectable = true,
required this.collection, required this.entries,
required this.scrollController, required this.scrollController,
required this.appBarHeightNotifier, required this.appBarHeightNotifier,
required this.child, required this.child,
}) : super(key: key); }) : super(key: key);
@override @override
_GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState(); _GridSelectionGestureDetectorState createState() => _GridSelectionGestureDetectorState<T>();
} }
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> { class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDetector<T>> {
bool _pressing = false, _selecting = false; bool _pressing = false, _selecting = false;
late int _fromIndex, _lastToIndex; late int _fromIndex, _lastToIndex;
late Offset _localPosition; late Offset _localPosition;
@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
late double _scrollSpeedFactor; late double _scrollSpeedFactor;
Timer? _updateTimer; Timer? _updateTimer;
CollectionLens get collection => widget.collection; List<T> get entries => widget.entries;
List<AvesEntry> get entries => collection.sortedEntries;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
@ -58,8 +55,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
final fromEntry = _getEntryAt(details.localPosition); final fromEntry = _getEntryAt(details.localPosition);
if (fromEntry == null) return; if (fromEntry == null) return;
collection.toggleSelection(fromEntry); final selection = context.read<Selection<T>>();
_selecting = collection.isSelected([fromEntry]); selection.toggleSelection(fromEntry);
_selecting = selection.isSelected([fromEntry]);
_fromIndex = entries.indexOf(fromEntry); _fromIndex = entries.indexOf(fromEntry);
_lastToIndex = _fromIndex; _lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only( _scrollableInsets = EdgeInsets.only(
@ -134,41 +132,42 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
} }
} }
AvesEntry? _getEntryAt(Offset localPosition) { T? _getEntryAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static, // 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, // 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 entry.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition; final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>(); final sectionedListLayout = context.read<SectionedListLayout<T>>();
return sectionedListLayout.getItemAt(offset); return sectionedListLayout.getItemAt(offset);
} }
void _toggleSelectionToIndex(int toIndex) { void _toggleSelectionToIndex(int toIndex) {
if (toIndex == -1) return; if (toIndex == -1) return;
final selection = context.read<Selection<T>>();
if (_selecting) { if (_selecting) {
if (toIndex <= _fromIndex) { if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) { if (toIndex < _lastToIndex) {
collection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); selection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_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) { } else if (_lastToIndex < toIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); selection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
} }
} else if (_fromIndex < toIndex) { } else if (_fromIndex < toIndex) {
if (_lastToIndex < 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) { if (_lastToIndex < _fromIndex) {
collection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); selection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
} }
} else if (toIndex < _lastToIndex) { } else if (toIndex < _lastToIndex) {
collection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); selection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
} }
} }
_lastToIndex = toIndex; _lastToIndex = toIndex;
} else { } else {
collection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1)); selection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
} }
} }
} }

View file

@ -0,0 +1,20 @@
import 'package:aves/model/selection.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
class SelectionProvider<T> extends StatelessWidget {
final Widget child;
const SelectionProvider({
Key? key,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<Selection<T>>(
create: (context) => Selection<T>(),
child: child,
);
}
}

View file

@ -1,9 +1,11 @@
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_grid.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.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/media_query_data_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -36,7 +38,8 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
value: ValueNotifier(AppMode.pickInternal), value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider( child: MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: GestureAreaProtectorStack( body: SelectionProvider<AvesEntry>(
child: GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value( child: ChangeNotifierProvider<CollectionLens>.value(
@ -49,6 +52,7 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
), ),
), ),
), ),
),
); );
} }
} }