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';
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();
}
}

View file

@ -1,5 +1,3 @@
enum Activity { browse, select }
enum SourceState { loading, cataloguing, locating, ready }
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/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(
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: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
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();

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/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,

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/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,10 +38,13 @@ class _CollectionPageState extends State<CollectionPage> {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: WillPopScope(
body: SelectionProvider<AvesEntry>(
child: Builder(
builder: (context) => WillPopScope(
onWillPop: () {
if (collection.isSelecting) {
collection.browse();
final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
selection.browse();
return SynchronousFuture(false);
}
return SynchronousFuture(true);
@ -57,6 +63,8 @@ class _CollectionPageState extends State<CollectionPage> {
),
),
),
),
),
drawer: const AppDrawer(),
resizeToAvoidBottomInset: false,
),

View file

@ -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;

View file

@ -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:

View file

@ -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,19 +58,15 @@ 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
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(selected),
icon: selected ? AIcons.selected : AIcons.unselected,
key: ValueKey(isSelected),
icon: isSelected ? AIcons.selected : AIcons.unselected,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
)
: const SizedBox.shrink();
@ -88,7 +83,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
child = AnimatedContainer(
duration: duration,
alignment: AlignmentDirectional.topEnd,
color: selected ? Colors.black54 : Colors.transparent,
color: isSelected ? Colors.black54 : Colors.transparent,
child: child,
);
return child;
@ -99,8 +94,6 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
duration: duration,
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/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,47 +144,14 @@ 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),
final isSelecting = context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
final Widget child = isSelecting
? _SectionSelectingLeading(
sectionKey: sectionKey,
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,
@ -205,9 +174,52 @@ class _SectionSelectableLeading extends StatelessWidget {
},
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,
),
),
),
);
}
}

View file

@ -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));
}
}
}

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/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,7 +38,8 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
value: ValueNotifier(AppMode.pickInternal),
child: MediaQueryDataProvider(
child: Scaffold(
body: GestureAreaProtectorStack(
body: SelectionProvider<AvesEntry>(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
@ -49,6 +52,7 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
),
),
),
),
);
}
}