multi selection generalization prep
This commit is contained in:
parent
981fd41e27
commit
81ab903d39
13 changed files with 303 additions and 268 deletions
45
lib/model/selection.dart
Normal file
45
lib/model/selection.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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<CollectionFilter> 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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
enum Activity { browse, select }
|
||||
|
||||
enum SourceState { loading, cataloguing, locating, ready }
|
||||
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
|
|
@ -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<CollectionAppBar> with SingleTickerPr
|
|||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
|
@ -63,6 +65,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<ValueNotifier<AppMode>>().value;
|
||||
return ValueListenableBuilder<Activity>(
|
||||
valueListenable: collection.activityNotifier,
|
||||
builder: (context, activity, child) {
|
||||
return Selector<Selection<AvesEntry>, 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<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBarLeading() {
|
||||
Widget _buildAppBarLeading(bool isSelecting) {
|
||||
VoidCallback? onPressed;
|
||||
String? tooltip;
|
||||
if (collection.isBrowsing) {
|
||||
if (isSelecting) {
|
||||
onPressed = () => context.read<Selection<AvesEntry>>().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<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
Widget? _buildAppBarTitle() {
|
||||
if (collection.isBrowsing) {
|
||||
Widget? _buildAppBarTitle(bool isSelecting) {
|
||||
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;
|
||||
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
|
||||
if (appMode == AppMode.main) {
|
||||
|
@ -158,36 +166,25 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<Widget> _buildActions() {
|
||||
List<Widget> _buildActions(bool isSelecting) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().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<Selection<AvesEntry>, 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<bool>(
|
||||
future: _canAddShortcutsLoader,
|
||||
|
@ -196,8 +193,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
return PopupMenuButton<CollectionAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
void _onActivityChange() {
|
||||
if (collection.isSelecting) {
|
||||
if (context.read<Selection<AvesEntry>>().isSelecting) {
|
||||
_browseToSelectAnimation.forward();
|
||||
} else {
|
||||
_browseToSelectAnimation.reverse();
|
||||
|
@ -289,13 +287,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_actionDelegate.onCollectionActionSelected(context, action);
|
||||
break;
|
||||
case CollectionAction.select:
|
||||
collection.select();
|
||||
context.read<Selection<AvesEntry>>().select();
|
||||
break;
|
||||
case CollectionAction.selectAll:
|
||||
collection.addToSelection(collection.sortedEntries);
|
||||
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
||||
break;
|
||||
case CollectionAction.selectNone:
|
||||
collection.clearSelection();
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
case CollectionAction.stats:
|
||||
_goToStats();
|
||||
|
|
|
@ -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<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
|
||||
final selector = GridSelectionGestureDetector(
|
||||
selectable: isMainMode,
|
||||
collection: collection,
|
||||
entries: collection.sortedEntries,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: appBarHeightNotifier,
|
||||
child: scaler,
|
||||
|
|
|
@ -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<CollectionPage> {
|
|||
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<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
key: Key('collection-grid'),
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: Builder(
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
key: Key('collection-grid'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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<CollectionLens>();
|
||||
AndroidAppService.shareEntries(collection.selection).then((success) {
|
||||
final selection = context.read<Selection<AvesEntry>>().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<CollectionLens>();
|
||||
collection.source.refreshMetadata(collection.selection);
|
||||
collection.browse();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
collection.source.refreshMetadata(selection.selection);
|
||||
selection.browse();
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
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) {
|
||||
// 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<void> _showDeleteDialog(BuildContext context) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
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<Selection<AvesEntry>>();
|
||||
final selectedItems = selection.selection;
|
||||
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = selectedItems.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
|
@ -207,12 +212,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
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;
|
||||
|
|
|
@ -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<ValueNotifier<AppMode>>().value;
|
||||
switch (appMode) {
|
||||
case AppMode.main:
|
||||
if (collection.isBrowsing) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.toggleSelection(entry);
|
||||
} else {
|
||||
_goToViewer(context);
|
||||
} else if (collection.isSelecting) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
break;
|
||||
case AppMode.pickExternal:
|
||||
|
|
|
@ -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<CollectionLens>();
|
||||
return ValueListenableBuilder<Activity>(
|
||||
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<ThumbnailThemeData, double>((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<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
|
||||
final child = isSelecting
|
||||
? Selector<Selection<AvesEntry>, 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<ThumbnailThemeData, double>((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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<CollectionLens>();
|
||||
final sectionEntries = collection.sections[sectionKey]!;
|
||||
final selected = collection.isSelected(sectionEntries);
|
||||
if (selected) {
|
||||
collection.removeFromSelection(sectionEntries);
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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<CollectionLens>();
|
||||
return ValueListenableBuilder<Activity>(
|
||||
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<Selection<AvesEntry>, 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<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T> extends StatefulWidget {
|
||||
final bool selectable;
|
||||
final CollectionLens collection;
|
||||
final List<T> entries;
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> 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<T>();
|
||||
}
|
||||
|
||||
class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetector> {
|
||||
class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDetector<T>> {
|
||||
bool _pressing = false, _selecting = false;
|
||||
late int _fromIndex, _lastToIndex;
|
||||
late Offset _localPosition;
|
||||
|
@ -38,9 +37,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
late double _scrollSpeedFactor;
|
||||
Timer? _updateTimer;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
List<AvesEntry> get entries => collection.sortedEntries;
|
||||
List<T> get entries => widget.entries;
|
||||
|
||||
ScrollController get scrollController => widget.scrollController;
|
||||
|
||||
|
@ -58,8 +55,9 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
|||
final fromEntry = _getEntryAt(details.localPosition);
|
||||
if (fromEntry == null) return;
|
||||
|
||||
collection.toggleSelection(fromEntry);
|
||||
_selecting = collection.isSelected([fromEntry]);
|
||||
final selection = context.read<Selection<T>>();
|
||||
selection.toggleSelection(fromEntry);
|
||||
_selecting = selection.isSelected([fromEntry]);
|
||||
_fromIndex = entries.indexOf(fromEntry);
|
||||
_lastToIndex = _fromIndex;
|
||||
_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,
|
||||
// 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.
|
||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
final sectionedListLayout = context.read<SectionedListLayout<T>>();
|
||||
return sectionedListLayout.getItemAt(offset);
|
||||
}
|
||||
|
||||
void _toggleSelectionToIndex(int toIndex) {
|
||||
if (toIndex == -1) return;
|
||||
|
||||
final selection = context.read<Selection<T>>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
20
lib/widgets/common/providers/selection_provider.dart
Normal file
20
lib/widgets/common/providers/selection_provider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<ItemPickDialog> {
|
|||
value: ValueNotifier(AppMode.pickInternal),
|
||||
child: MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue