#197 preview button when selecting items

This commit is contained in:
Thibault Deckers 2023-03-08 19:56:56 +01:00
parent 09cf4fef3e
commit 433ac537dd
17 changed files with 225 additions and 165 deletions

View file

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- Collection: preview button when selecting items
- Vaults: custom pattern lock
- Video: picture-in-picture
- Video: handle skip next/previous media buttons

View file

@ -19,6 +19,11 @@ extension ExtraAppMode on AppMode {
AppMode.pickMultipleMediaExternal,
}.contains(this);
bool get canEditEntry => {
AppMode.main,
AppMode.view,
}.contains(this);
bool get canSelectMedia => {
AppMode.main,
AppMode.pickMultipleMediaExternal,

View file

@ -254,8 +254,11 @@ class _CollectionGridContentState extends State<_CollectionGridContent> {
);
if (selection.isSelecting) {
child = ChangeNotifierProvider<Selection<AvesEntry>>.value(
value: selection,
child = MultiProvider(
providers: [
ListenableProvider<ValueNotifier<AppMode>>.value(value: ValueNotifier(AppMode.pickMediaInternal)),
ChangeNotifierProvider<Selection<AvesEntry>>.value(value: selection),
],
child: child,
);
}

View file

@ -73,8 +73,8 @@ class _AnimatedDiffTextState extends State<AnimatedDiffText> with SingleTickerPr
children: _diffs.map((diff) {
final oldText = diff.item1;
final newText = diff.item2;
final oldWidth = diff.item3;
final newWidth = diff.item4;
final oldSize = diff.item3;
final newSize = diff.item4;
final text = (_animation.value == 0 ? oldText : newText) ?? '';
return WidgetSpan(
child: AnimatedSize(
@ -91,9 +91,10 @@ class _AnimatedDiffTextState extends State<AnimatedDiffText> with SingleTickerPr
children: [
...previousChildren.map(
(child) => ConstrainedBox(
constraints: BoxConstraints(
maxWidth: min(oldWidth, newWidth),
),
constraints: BoxConstraints.tight(Size(
min(oldSize.width, newSize.width),
min(oldSize.height, newSize.height),
)),
child: child,
),
),
@ -116,14 +117,16 @@ class _AnimatedDiffTextState extends State<AnimatedDiffText> with SingleTickerPr
);
}
double textWidth(String text) {
Size textSize(String text) {
final para = RenderParagraph(
TextSpan(text: text, style: widget.textStyle),
textDirection: Directionality.of(context),
textScaleFactor: MediaQuery.textScaleFactorOf(context),
strutStyle: widget.strutStyle,
)..layout(const BoxConstraints(), parentUsesSize: true);
return para.getMaxIntrinsicWidth(double.infinity);
final width = para.getMaxIntrinsicWidth(double.infinity);
final height = para.getMaxIntrinsicHeight(double.infinity);
return Size(width, height);
}
// use an adaptation of Google's `Diff Match and Patch`
@ -140,15 +143,15 @@ class _AnimatedDiffTextState extends State<AnimatedDiffText> with SingleTickerPr
..clear()
..addAll(d.map((diff) {
final text = diff.text;
final size = textSize(text);
switch (diff.operation) {
case Operation.delete:
return Tuple4(text, null, textWidth(text), .0);
return Tuple4(text, null, size, Size.zero);
case Operation.insert:
return Tuple4(null, text, .0, textWidth(text));
return Tuple4(null, text, Size.zero, size);
case Operation.equal:
default:
final width = textWidth(text);
return Tuple4(text, text, width, width);
return Tuple4(text, text, size, size);
}
}).fold<List<_TextDiff>>([], (prev, v) {
if (prev.isNotEmpty) {
@ -168,109 +171,6 @@ class _AnimatedDiffTextState extends State<AnimatedDiffText> with SingleTickerPr
return [...prev, v];
}));
}
// void _computeDiff(String oldText, String newText) {
// final oldCharacters = oldText.characters.toList();
// final newCharacters = newText.characters.toList();
// final diffResult = calculateListDiff<String>(oldCharacters, newCharacters, detectMoves: false);
// final updates = diffResult.getUpdatesWithData().toList();
// List<TextDiff> diffs = [];
// DataDiffUpdate<String>? pendingUpdate;
// int lastPos = oldCharacters.length;
// void addKeep(int pos) {
// if (pos < lastPos) {
// final text = oldCharacters.sublist(pos, lastPos).join();
// final width = textWidth(text);
// diffs.insert(0, Tuple4(text, text, width, width));
// lastPos = pos;
// }
// }
//
// void commit(DataDiffUpdate<String>? update) {
// update?.when(
// insert: (pos, data) {
// addKeep(pos);
// diffs.insert(0, Tuple4(null, data, 0, textWidth(data)));
// lastPos = pos;
// },
// remove: (pos, data) {
// addKeep(pos + data.length);
// diffs.insert(0, Tuple4(data, null, textWidth(data), 0));
// lastPos = pos;
// },
// change: (pos, oldData, newData) {
// addKeep(pos + oldData.length);
// diffs.insert(0, Tuple4(oldData, newData, textWidth(oldData), textWidth(newData)));
// lastPos = pos;
// },
// move: (from, to, data) {
// assert(false, '`move` update: from=$from, to=$from, data=$data');
// },
// );
// }
//
// for (var update in updates) {
// update.when(
// insert: (pos, data) {
// if (pendingUpdate == null) {
// pendingUpdate = update;
// return;
// }
// if (pendingUpdate is DataInsert) {
// final pendingInsert = pendingUpdate as DataInsert;
// if (pendingInsert.position == pos) {
// // merge insertions
// pendingUpdate = DataInsert(position: pos, data: data + pendingInsert.data);
// return;
// }
// } else if (pendingUpdate is DataRemove) {
// final pendingRemove = pendingUpdate as DataRemove;
// if (pendingRemove.position == pos) {
// // convert to change
// pendingUpdate = DataChange(position: pos, oldData: pendingRemove.data, newData: data);
// return;
// }
// } else if (pendingUpdate is DataChange) {
// final pendingChange = pendingUpdate as DataChange;
// if (pendingChange.position == pos) {
// // merge changes
// pendingUpdate = DataChange(position: pos, oldData: pendingChange.oldData, newData: data + pendingChange.newData);
// return;
// }
// }
// commit(pendingUpdate);
// pendingUpdate = update;
// },
// remove: (pos, data) {
// if (pendingUpdate == null) {
// pendingUpdate = update;
// return;
// }
// if (pendingUpdate is DataRemove) {
// final pendingRemove = pendingUpdate as DataRemove;
// if (pendingRemove.position == pos + data.length) {
// // merge removals
// pendingUpdate = DataRemove(position: pos, data: data + pendingRemove.data);
// return;
// }
// }
// commit(pendingUpdate);
// pendingUpdate = update;
// },
// change: (pos, oldData, newData) {
// assert(false, '`change` update: from=$pos, oldData=$oldData, newData=$newData');
// },
// move: (from, to, data) {
// assert(false, '`move` update: from=$from, to=$from, data=$data');
// },
// );
// }
// commit(pendingUpdate);
// addKeep(0);
// _diffs
// ..clear()
// ..addAll(diffs);
// }
}
typedef _TextDiff = Tuple4<String?, String?, double, double>;
typedef _TextDiff = Tuple4<String?, String?, Size, Size>;

View file

@ -26,7 +26,7 @@ class GridItemSelectionOverlay<T> extends StatelessWidget {
duration: duration,
child: isSelecting
? Selector<Selection<T>, bool>(
selector: (context, selection) => selection.isSelected([item]),
selector: (context, selection) => selection.isSelected({item}),
builder: (context, isSelected, child) {
return AnimatedContainer(
alignment: AlignmentDirectional.topEnd,

View file

@ -51,6 +51,8 @@ class MapButtonPanel extends StatelessWidget {
);
}
break;
case MapNavigationButton.none:
break;
}
final showCoordinateFilter = context.select<MapThemeData, bool>((v) => v.showCoordinateFilter);

View file

@ -2,6 +2,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/common/thumbnail/notifications.dart';
import 'package:aves/widgets/common/thumbnail/overlay.dart';
import 'package:flutter/material.dart';
@ -44,11 +45,15 @@ class DecoratedThumbnail extends StatelessWidget {
children: [
child,
ThumbnailEntryOverlay(entry: entry),
if (selectable)
if (selectable) ...[
GridItemSelectionOverlay<AvesEntry>(
item: entry,
padding: const EdgeInsets.all(2),
),
ThumbnailZoomOverlay(
onZoom: () => OpenViewerNotification(entry).dispatch(context),
),
],
if (highlightable) ThumbnailHighlightOverlay(entry: entry),
],
);

View file

@ -47,7 +47,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
EntryActionDelegate(this.mainEntry, this.pageEntry, this.collection);
bool isVisible(EntryAction action) {
bool isVisible({
required AppMode appMode,
required EntryAction action,
}) {
if (mainEntry.trashed) {
switch (action) {
case EntryAction.delete:
@ -60,7 +63,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
} else {
final targetEntry = EntryActions.pageActions.contains(action) ? pageEntry : mainEntry;
final canWrite = !settings.isReadOnly;
final canWrite = appMode.canEditEntry && !settings.isReadOnly;
switch (action) {
case EntryAction.toggleFavourite:
return collection != null;
@ -120,7 +123,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.showGeoTiffOnMap:
case EntryAction.convertMotionPhotoToStillImage:
case EntryAction.viewMotionPhotoVideo:
return _metadataActionDelegate.isVisible(targetEntry, action);
return _metadataActionDelegate.isVisible(
appMode: appMode,
targetEntry: targetEntry,
action: action,
);
case EntryAction.debug:
return kDebugMode;
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/events.dart';
import 'package:aves/model/entry.dart';
@ -29,8 +30,12 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
Stream<ActionEvent<EntryAction>> get eventStream => _eventStreamController.stream;
bool isVisible(AvesEntry targetEntry, EntryAction action) {
final canWrite = !settings.isReadOnly;
bool isVisible({
required AppMode appMode,
required AvesEntry targetEntry,
required EntryAction action,
}) {
final canWrite = appMode.canEditEntry && !settings.isReadOnly;
switch (action) {
// general
case EntryAction.editDate:
@ -39,16 +44,17 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
case EntryAction.editRating:
case EntryAction.editTags:
case EntryAction.removeMetadata:
case EntryAction.exportMetadata:
return canWrite;
case EntryAction.exportMetadata:
return true;
// GeoTIFF
case EntryAction.showGeoTiffOnMap:
return targetEntry.isGeotiff;
return appMode.canNavigate && targetEntry.isGeotiff;
// motion photo
case EntryAction.convertMotionPhotoToStillImage:
return canWrite && targetEntry.isMotionPhoto;
case EntryAction.viewMotionPhotoVideo:
return targetEntry.isMotionPhoto;
return appMode.canNavigate && targetEntry.isMotionPhoto;
default:
return false;
}

View file

@ -562,12 +562,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
void _onVerticalPageChanged(int page) {
_currentVerticalPage.value = page;
if (page == transitionPage) {
switch (page) {
case transitionPage:
dismissFeedback(context);
_popVisual();
} else if (page == infoPage) {
break;
case imagePage:
reportService.log('Nav move to Image page');
break;
case infoPage:
reportService.log('Nav move to Info page');
// prevent hero when viewer is offscreen
_heroInfoNotifier.value = null;
break;
}
}

View file

@ -150,11 +150,20 @@ class _BasicSectionState extends State<BasicSection> {
}
Widget _buildEditButtons(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final entry = widget.entry;
final children = [
EntryAction.editRating,
EntryAction.editTags,
].where((v) => actionDelegate.canApply(entry, v)).map((v) => _buildEditMetadataButton(context, v)).toList();
]
.where((v) => actionDelegate.isVisible(
appMode: appMode,
targetEntry: entry,
action: v,
))
.where((v) => actionDelegate.canApply(entry, v))
.map((v) => _buildEditMetadataButton(context, v))
.toList();
return children.isEmpty
? const SizedBox()

View file

@ -1,3 +1,4 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
@ -14,6 +15,7 @@ import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class InfoAppBar extends StatelessWidget {
final AvesEntry entry;
@ -33,8 +35,14 @@ class InfoAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final commonActions = EntryActions.commonMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntryAction action) => actionDelegate.isVisible(
appMode: appMode,
targetEntry: entry,
action: action,
);
final commonActions = EntryActions.commonMetadataActions.where(isVisible);
final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where(isVisible);
final useTvLayout = settings.useTvLayout;
return SliverAppBar(
leading: useTvLayout

View file

@ -1,3 +1,4 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/enums/coordinate_format.dart';
@ -13,6 +14,7 @@ import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves_map/aves_map.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class LocationSection extends StatefulWidget {
final CollectionLens? collection;
@ -72,6 +74,7 @@ class _LocationSectionState extends State<LocationSection> {
Widget build(BuildContext context) {
if (!entry.hasGps) return const SizedBox();
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -79,7 +82,7 @@ class _LocationSectionState extends State<LocationSection> {
MapTheme(
interactive: false,
showCoordinateFilter: false,
navigationButton: MapNavigationButton.map,
navigationButton: canNavigate ? MapNavigationButton.map : MapNavigationButton.none,
visualDensity: VisualDensity.compact,
mapHeight: 200,
child: GeoMap(
@ -87,7 +90,7 @@ class _LocationSectionState extends State<LocationSection> {
entries: [entry],
isAnimatingNotifier: widget.isScrollingNotifier,
onUserZoomChange: (zoom) => settings.infoMapZoom = zoom.roundToDouble(),
onMarkerTap: collection != null ? (location, entry) => _openMapPage(context) : null,
onMarkerTap: collection != null && canNavigate ? (location, entry) => _openMapPage(context) : null,
openMapPage: collection != null ? _openMapPage : null,
),
),

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.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/widgets/common/extensions/media_query.dart';
@ -9,6 +10,7 @@ import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/overlay/multipage.dart';
import 'package:aves/widgets/viewer/overlay/selection_button.dart';
import 'package:aves/widgets/viewer/overlay/thumbnail_preview.dart';
import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart';
import 'package:aves/widgets/viewer/overlay/wallpaper_buttons.dart';
@ -183,7 +185,13 @@ class _BottomOverlayContentState extends State<_BottomOverlayContent> {
]),
builder: (context, child) {
final viewInsetsPadding = (widget.viewInsets ?? EdgeInsets.zero) + (widget.viewPadding ?? EdgeInsets.zero);
final viewerButtonRow = FocusableActionDetector(
final selection = context.read<Selection<AvesEntry>?>();
final viewerButtonRow = (selection?.isSelecting ?? false)
? SelectionButton(
mainEntry: mainEntry,
scale: _buttonScale,
)
: FocusableActionDetector(
focusNode: _buttonRowFocusScopeNode,
shortcuts: settings.useTvLayout ? const {SingleActivator(LogicalKeyboardKey.arrowUp): TvShowLessInfoIntent()} : null,
actions: {TvShowLessInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context))},

View file

@ -0,0 +1,83 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/text/animated_diff.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SelectionButton extends StatelessWidget {
final AvesEntry mainEntry;
final Animation<double> scale;
static const double padding = 8;
static const duration = Durations.thumbnailOverlayAnimation;
const SelectionButton({
super.key,
required this.mainEntry,
required this.scale,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final selection = context.read<Selection<AvesEntry>>();
return SafeArea(
top: false,
bottom: false,
child: Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Spacer(),
ScalingOverlayTextButton(
scale: scale,
onPressed: () => selection.toggleSelection(mainEntry),
child: Selector<Selection<AvesEntry>?, int>(
selector: (context, selection) => selection?.selectedItems.length ?? 0,
builder: (context, count, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedDiffText(
count == 0 ? l10n.collectionSelectPageTitle : l10n.itemCount(count),
duration: duration,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(Constants.separator),
),
Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.isSelected({mainEntry}),
builder: (context, isSelected, child) {
return AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: Icon(
isSelected ? AIcons.selected : AIcons.unselected,
key: ValueKey(isSelected),
),
);
},
),
],
);
},
),
),
],
),
),
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
@ -61,6 +62,12 @@ class ViewerButtons extends StatelessWidget {
);
}
final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntryAction action) => actionDelegate.isVisible(
appMode: appMode,
action: action,
);
final trashed = mainEntry.trashed;
return SafeArea(
top: false,
@ -72,9 +79,9 @@ class ViewerButtons extends StatelessWidget {
return Selector<Settings, bool>(
selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) {
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(actionDelegate.isVisible).where(actionDelegate.canApply).take(availableCount - 1).toList();
final quickActions = (trashed ? EntryActions.trashed : settings.viewerQuickActions).where(isVisible).where(actionDelegate.canApply).take(availableCount - 1).toList();
List<EntryAction> getMenuActions(List<EntryAction> categoryActions) {
return categoryActions.where((action) => !quickActions.contains(action)).where(actionDelegate.isVisible).toList();
return categoryActions.where((action) => !quickActions.contains(action)).where(isVisible).toList();
}
return ViewerButtonRowContent(
@ -109,6 +116,7 @@ class _TvButtonRowContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry),
builder: (context, videoController, child) {
@ -120,7 +128,12 @@ class _TvButtonRowContent extends StatelessWidget {
...EntryActions.export,
...EntryActions.videoPlayback,
...EntryActions.video,
].where(actionDelegate.isVisible).map((action) {
]
.where((action) => actionDelegate.isVisible(
appMode: appMode,
action: action,
))
.map((action) {
final enabled = actionDelegate.canApply(action);
return CaptionedButton(
scale: scale,

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
enum MapNavigationButton { back, map }
enum MapNavigationButton { back, map, none }
class MapThemeData {
final bool interactive, showCoordinateFilter;