#526 collection: bulk converting

This commit is contained in:
Thibault Deckers 2023-02-23 11:46:01 +01:00
parent af6fd8f11b
commit 8f732608d0
16 changed files with 367 additions and 139 deletions

View file

@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### Added
- Collection: bulk converting
- Places: page & navigation entry
### Fixed

View file

@ -50,7 +50,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
when (op) {
"delete" -> ioScope.launch { delete() }
"export" -> ioScope.launch { export() }
"convert" -> ioScope.launch { convert() }
"move" -> ioScope.launch { move() }
"rename" -> ioScope.launch { rename() }
else -> endOfStream()
@ -121,7 +121,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
endOfStream()
}
private suspend fun export() {
private suspend fun convert() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
@ -129,11 +129,12 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
val lengthUnit = arguments["lengthUnit"] as String?
val width = (arguments["width"] as Number?)?.toInt()
val height = (arguments["height"] as Number?)?.toInt()
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
error("export-args", "missing arguments", null)
if (destinationDir == null || mimeType == null || lengthUnit == null || width == null || height == null || nameConflictStrategy == null) {
error("convert-args", "missing arguments", null)
return
}
@ -141,15 +142,15 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("export-provider", "failed to find provider for entry=$firstEntry", null)
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
return
}
destinationDir = ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
provider.convertMultiple(activity, mimeType, destinationDir, entries, lengthUnit, width, height, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
override fun onFailure(throwable: Throwable) = error("convert-failure", "failed to convert entries", throwable)
})
endOfStream()
}

View file

@ -15,6 +15,15 @@ class AvesEntry(map: FieldMap) {
val trashed = map["trashed"] as Boolean
val trashPath = map["trashPath"] as String?
private val isRotated: Boolean
get() = rotationDegrees % 180 == 90
val displayWidth: Int
get() = if (isRotated) height else width
val displayHeight: Int
get() = if (isRotated) width else height
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {

View file

@ -169,11 +169,12 @@ abstract class ImageProvider {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
}
suspend fun exportMultiple(
suspend fun convertMultiple(
activity: Activity,
imageExportMimeType: String,
targetDir: String,
entries: List<AvesEntry>,
lengthUnit: String,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
@ -208,6 +209,7 @@ abstract class ImageProvider {
sourceEntry = entry,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
lengthUnit = lengthUnit,
width = width,
height = height,
nameConflictStrategy = nameConflictStrategy,
@ -227,6 +229,7 @@ abstract class ImageProvider {
sourceEntry: AvesEntry,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
lengthUnit: String,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
@ -266,6 +269,19 @@ abstract class ImageProvider {
sourceDocFile.copyTo(output)
}
} else {
val targetWidthPx: Int
val targetHeightPx: Int
when (lengthUnit) {
LENGTH_UNIT_PERCENT -> {
targetWidthPx = sourceEntry.displayWidth * width / 100
targetHeightPx = sourceEntry.displayHeight * height / 100
}
else -> {
targetWidthPx = width
targetHeightPx = height
}
}
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
@ -286,7 +302,7 @@ abstract class ImageProvider {
.asBitmap()
.apply(glideOptions)
.load(model)
.submit(width, height)
.submit(targetWidthPx, targetHeightPx)
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
@ -1209,6 +1225,8 @@ abstract class ImageProvider {
companion object {
private val LOG_TAG = LogUtils.createTag<ImageProvider>()
private const val LENGTH_UNIT_PERCENT = "percent"
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
// used when skipping a move/creation op because the target file already exists

View file

@ -200,6 +200,9 @@
"keepScreenOnViewerOnly": "Viewer page only",
"keepScreenOnAlways": "Always",
"lengthUnitPixel": "px",
"lengthUnitPercent": "%",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Terrain)",

View file

@ -25,6 +25,7 @@ enum EntrySetAction {
copy,
move,
rename,
convert,
toggleFavourite,
rotateCCW,
rotateCW,
@ -45,13 +46,16 @@ class EntrySetActions {
EntrySetAction.selectNone,
];
// `null` items are converted to dividers
static const pageBrowsing = [
EntrySetAction.searchCollection,
EntrySetAction.toggleTitleSearch,
EntrySetAction.addShortcut,
null,
EntrySetAction.map,
EntrySetAction.slideshow,
EntrySetAction.stats,
null,
EntrySetAction.rescan,
EntrySetAction.emptyBin,
];
@ -67,6 +71,7 @@ class EntrySetActions {
EntrySetAction.rescan,
];
// `null` items are converted to dividers
static const pageSelection = [
EntrySetAction.share,
EntrySetAction.delete,
@ -74,10 +79,13 @@ class EntrySetActions {
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.rename,
EntrySetAction.convert,
EntrySetAction.toggleFavourite,
null,
EntrySetAction.map,
EntrySetAction.slideshow,
EntrySetAction.stats,
null,
EntrySetAction.rescan,
// editing actions are in their subsection
];
@ -89,6 +97,7 @@ class EntrySetActions {
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.rename,
EntrySetAction.convert,
EntrySetAction.toggleFavourite,
EntrySetAction.map,
EntrySetAction.slideshow,
@ -163,6 +172,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionMove;
case EntrySetAction.rename:
return context.l10n.entryActionRename;
case EntrySetAction.convert:
return context.l10n.entryActionConvert;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return context.l10n.entryActionAddFavourite;
@ -232,6 +243,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.move;
case EntrySetAction.rename:
return AIcons.name;
case EntrySetAction.convert:
return AIcons.convert;
case EntrySetAction.toggleFavourite:
// different data depending on toggle state
return AIcons.favourite;

View file

@ -15,6 +15,8 @@ enum DateFieldSource {
exifGpsDate,
}
enum LengthUnit { px, percent }
enum LocationEditAction {
chooseOnMap,
copyItem,

View file

@ -0,0 +1,14 @@
import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
extension ExtraLengthUnit on LengthUnit {
String getText(BuildContext context) {
switch (this) {
case LengthUnit.px:
return context.l10n.lengthUnitPixel;
case LengthUnit.percent:
return context.l10n.lengthUnitPercent;
}
}
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
@ -28,7 +29,7 @@ abstract class MediaEditService {
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
@ -113,16 +114,17 @@ class PlatformMediaEditService implements MediaEditService {
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required EntryExportOptions options,
required EntryConvertOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
try {
return _opStream
.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'op': 'convert',
'entries': entries.map((entry) => entry.toPlatformEntryMap()).toList(),
'mimeType': options.mimeType,
'lengthUnit': options.lengthUnit.name,
'width': options.width,
'height': options.height,
'destinationPath': destinationAlbum,
@ -183,15 +185,17 @@ class PlatformMediaEditService implements MediaEditService {
}
@immutable
class EntryExportOptions extends Equatable {
class EntryConvertOptions extends Equatable {
final String mimeType;
final LengthUnit lengthUnit;
final int width, height;
@override
List<Object?> get props => [mimeType, width, height];
List<Object?> get props => [mimeType, lengthUnit, width, height];
const EntryExportOptions({
const EntryConvertOptions({
required this.mimeType,
required this.lengthUnit,
required this.width,
required this.height,
});

View file

@ -32,6 +32,7 @@ import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
@ -342,7 +343,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return [
...EntrySetActions.general,
...isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing,
].where(isVisible).map((action) {
].whereNotNull().where(isVisible).map((action) {
final enabled = canApply(action);
return CaptionedButton(
iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
@ -388,10 +389,21 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final browsingMenuActions = EntrySetActions.pageBrowsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = EntrySetActions.pageSelection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuItems = [
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
(action) => _toMenuItem(action, enabled: canApply(action), selection: selection),
),
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold(<EntrySetAction?>[], (prev, v) {
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
return [...prev, v];
});
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) {
contextualMenuActions.removeLast();
}
final contextualMenuItems = <PopupMenuEntry<EntrySetAction>>[
...contextualMenuActions.map(
(action) {
if (action == null) return const PopupMenuDivider();
return _toMenuItem(action, enabled: canApply(action), selection: selection);
},
),
if (isSelecting && !settings.isReadOnly && appMode == AppMode.main && !isTrash)
PopupMenuItem<EntrySetAction>(
enabled: hasSelection,
@ -630,6 +642,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:

View file

@ -94,6 +94,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
case EntrySetAction.flip:
@ -145,6 +146,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.rename:
case EntrySetAction.convert:
case EntrySetAction.toggleFavourite:
case EntrySetAction.rotateCCW:
case EntrySetAction.rotateCW:
@ -211,6 +213,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.rename:
_rename(context);
break;
case EntrySetAction.convert:
_convert(context);
break;
case EntrySetAction.toggleFavourite:
_toggleFavourite(context);
break;
@ -379,6 +384,13 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_browse(context);
}
void _convert(BuildContext context) {
final entries = _getTargetItems(context);
convert(context, entries);
_browse(context);
}
Future<void> _toggleFavourite(BuildContext context) async {
final entries = _getTargetItems(context);
if (entries.every((entry) => entry.isFavourite)) {

View file

@ -16,6 +16,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/aves_app.dart';
@ -27,6 +28,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/convert_entry_dialog.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:collection/collection.dart';
@ -34,6 +36,100 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> convert(BuildContext context, Set<AvesEntry> targetEntries) async {
final options = await showDialog<EntryConvertOptions>(
context: context,
builder: (context) => ConvertEntryDialog(entries: targetEntries),
routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName),
);
if (options == null) return;
final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
await Future.forEach(targetEntries, (targetEntry) async {
if (targetEntry.isMultiPage) {
final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) {
if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
selection.addAll(multiPageInfo.exportEntries);
}
}
} else {
selection.add(targetEntry);
}
});
final selectionCount = selection.length;
final source = context.read<CollectionSource>();
source.pauseMonitoring();
await showOpReport<ExportOpEvent>(
context: context,
opStream: mediaEditService.export(
selection,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
),
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
source.resumeMonitoring();
unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
final showAction = isMainMode && newUris.isNotEmpty
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () {
// local context may be deactivated when action is triggered after navigation
final context = AvesApp.navigatorKey.currentContext;
if (context != null) {
Navigator.maybeOf(context)?.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => newUris.contains(entry.uri),
),
),
(route) => false,
);
}
},
)
: null;
final successCount = successOps.length;
if (successCount < selectionCount) {
final count = selectionCount - successCount;
showFeedback(
context,
l10n.collectionExportFailureFeedback(count),
showAction,
);
} else {
showFeedback(
context,
l10n.genericSuccessFeedback,
showAction,
);
}
},
);
}
Future<void> doQuickMove(
BuildContext context, {
required MoveType moveType,

View file

@ -1,6 +1,9 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/enums/enums.dart';
import 'package:aves/model/metadata/enums/length_unit.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -8,26 +11,29 @@ import 'package:flutter/material.dart';
import 'aves_dialog.dart';
class ExportEntryDialog extends StatefulWidget {
static const routeName = '/dialog/export_entry';
class ConvertEntryDialog extends StatefulWidget {
static const routeName = '/dialog/convert_entry';
final AvesEntry entry;
final Set<AvesEntry> entries;
const ExportEntryDialog({
const ConvertEntryDialog({
super.key,
required this.entry,
required this.entries,
});
@override
State<ExportEntryDialog> createState() => _ExportEntryDialogState();
State<ConvertEntryDialog> createState() => _ConvertEntryDialogState();
}
class _ExportEntryDialogState extends State<ExportEntryDialog> {
class _ConvertEntryDialogState extends State<ConvertEntryDialog> {
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
String _mimeType = MimeTypes.jpeg;
late String _mimeType;
late bool _sameSized;
late List<LengthUnit> _lengthUnitOptions;
late LengthUnit _lengthUnit;
AvesEntry get entry => widget.entry;
Set<AvesEntry> get entries => widget.entries;
static const imageExportFormats = [
MimeTypes.bmp,
@ -39,11 +45,31 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
@override
void initState() {
super.initState();
_widthController.text = '${entry.isRotated ? entry.height : entry.width}';
_heightController.text = '${entry.isRotated ? entry.width : entry.height}';
_mimeType = MimeTypes.jpeg;
_sameSized = entries.map((entry) => entry.displaySize).toSet().length == 1;
_lengthUnitOptions = [
if (_sameSized) LengthUnit.px,
LengthUnit.percent,
];
_lengthUnit = _lengthUnitOptions.first;
_initDimensions();
_validate();
}
void _initDimensions() {
switch (_lengthUnit) {
case LengthUnit.px:
final displaySize = entries.first.displaySize;
_widthController.text = '${displaySize.width.round()}';
_heightController.text = '${displaySize.height.round()}';
break;
case LengthUnit.percent:
_widthController.text = '100';
_heightController.text = '100';
break;
}
}
@override
void dispose() {
_widthController.dispose();
@ -56,6 +82,14 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
final l10n = context.l10n;
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
// used by the drop down to match input decoration
final textFieldDecorationBorder = Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.38), //Color(0xFFBDBDBD),
width: 1.0,
),
);
return AvesDialog(
scrollableContent: [
const SizedBox(height: 16),
@ -92,12 +126,25 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
_heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : '';
if (width != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_heightController.text = '${(width / entries.first.displayAspectRatio).round()}';
break;
case LengthUnit.percent:
_heightController.text = '$width';
break;
}
} else {
_heightController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 8),
const Text(AvesEntry.resolutionSeparator),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _heightController,
@ -105,11 +152,46 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
_widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : '';
if (height != null) {
switch (_lengthUnit) {
case LengthUnit.px:
_widthController.text = '${(height * entries.first.displayAspectRatio).round()}';
break;
case LengthUnit.percent:
_widthController.text = '$height';
break;
}
} else {
_widthController.text = '';
}
_validate();
},
),
),
const SizedBox(width: 16),
TextDropdownButton<LengthUnit>(
values: _lengthUnitOptions,
valueText: (v) => v.getText(context),
value: _lengthUnit,
onChanged: _lengthUnitOptions.length > 1
? (v) {
if (v != null && _lengthUnit != v) {
_lengthUnit = v;
_initDimensions();
_validate();
setState(() {});
}
}
: null,
underline: Container(
height: 1.0,
decoration: BoxDecoration(
border: textFieldDecorationBorder,
),
),
itemHeight: 60,
dropdownColor: Themes.thirdLayerColor(context),
),
],
),
),
@ -126,8 +208,9 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
final options = (width != null && height != null)
? EntryExportOptions(
? EntryConvertOptions(
mimeType: _mimeType,
lengthUnit: _lengthUnit,
width: width,
height: height,
)

View file

@ -327,9 +327,13 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v));
final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v));
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).toList();
if (contextualMenuActions.isNotEmpty && contextualMenuActions.first == null) contextualMenuActions.removeAt(0);
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) contextualMenuActions.removeLast();
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).fold(<ChipSetAction?>[], (prev, v) {
if (v == null && (prev.isEmpty || prev.last == null)) return prev;
return [...prev, v];
});
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) {
contextualMenuActions.removeLast();
}
return [
...generalMenuItems,

View file

@ -8,20 +8,14 @@ import 'package:aves/model/actions/share_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/vaults/vaults.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_edit_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -32,8 +26,6 @@ import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/action/printer.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.dart';
@ -42,7 +34,6 @@ import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/source_viewer_page.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -197,7 +188,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
_move(context, targetEntry, moveType: MoveType.fromBin);
break;
case EntryAction.convert:
_convert(context, targetEntry);
convert(context, {targetEntry});
break;
case EntryAction.print:
EntryPrinter(targetEntry).print(context);
@ -412,98 +403,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
Future<void> _convert(BuildContext context, AvesEntry targetEntry) async {
final options = await showDialog<EntryExportOptions>(
context: context,
builder: (context) => ExportEntryDialog(entry: targetEntry),
routeSettings: const RouteSettings(name: ExportEntryDialog.routeName),
);
if (options == null) return;
final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkFreeSpaceForMove(context, {targetEntry}, destinationAlbum, MoveType.export)) return;
final selection = <AvesEntry>{};
if (targetEntry.isMultiPage) {
final multiPageInfo = await targetEntry.getMultiPageInfo();
if (multiPageInfo != null) {
if (targetEntry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo();
}
if (multiPageInfo.pageCount > 1) {
selection.addAll(multiPageInfo.exportEntries);
}
}
} else {
selection.add(targetEntry);
}
final selectionCount = selection.length;
final source = context.read<CollectionSource>();
source.pauseMonitoring();
await showOpReport<ExportOpEvent>(
context: context,
opStream: mediaEditService.export(
selection,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
),
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
source.resumeMonitoring();
unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
final showAction = isMainMode && newUris.isNotEmpty
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () {
// local context may be deactivated when action is triggered after navigation
final context = AvesApp.navigatorKey.currentContext;
if (context != null) {
Navigator.maybeOf(context)?.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
source: source,
filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))},
highlightTest: (entry) => newUris.contains(entry.uri),
),
),
(route) => false,
);
}
},
)
: null;
final successCount = successOps.length;
if (successCount < selectionCount) {
final count = selectionCount - successCount;
showFeedback(
context,
l10n.collectionExportFailureFeedback(count),
showAction,
);
} else {
showFeedback(
context,
l10n.genericSuccessFeedback,
showAction,
);
}
},
);
}
Future<void> _move(BuildContext context, AvesEntry targetEntry, {required MoveType moveType}) => doMove(
context,
moveType: moveType,

View file

@ -116,6 +116,8 @@
"keepScreenOnVideoPlayback",
"keepScreenOnViewerOnly",
"keepScreenOnAlways",
"lengthUnitPixel",
"lengthUnitPercent",
"mapStyleGoogleNormal",
"mapStyleGoogleHybrid",
"mapStyleGoogleTerrain",
@ -600,6 +602,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -627,6 +631,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -650,6 +656,8 @@
"el": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -657,6 +665,8 @@
"es": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -668,6 +678,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -725,6 +737,8 @@
"keepScreenOnVideoPlayback",
"keepScreenOnViewerOnly",
"keepScreenOnAlways",
"lengthUnitPixel",
"lengthUnitPercent",
"nameConflictStrategySkip",
"vaultLockTypePin",
"vaultLockTypePassword",
@ -1156,6 +1170,8 @@
"fr": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -1187,6 +1203,8 @@
"displayRefreshRatePreferHighest",
"displayRefreshRatePreferLowest",
"keepScreenOnVideoPlayback",
"lengthUnitPixel",
"lengthUnitPercent",
"subtitlePositionTop",
"subtitlePositionBottom",
"themeBrightnessLight",
@ -1793,6 +1811,8 @@
"keepScreenOnVideoPlayback",
"keepScreenOnViewerOnly",
"keepScreenOnAlways",
"lengthUnitPixel",
"lengthUnitPercent",
"mapStyleGoogleNormal",
"mapStyleGoogleHybrid",
"mapStyleGoogleTerrain",
@ -2286,6 +2306,8 @@
"id": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -2297,6 +2319,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -2332,6 +2356,8 @@
"filterTaggedLabel",
"albumTierVaults",
"keepScreenOnVideoPlayback",
"lengthUnitPixel",
"lengthUnitPercent",
"subtitlePositionTop",
"subtitlePositionBottom",
"vaultLockTypePin",
@ -2364,6 +2390,8 @@
"ko": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -2379,6 +2407,8 @@
"filterTaggedLabel",
"albumTierVaults",
"keepScreenOnVideoPlayback",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -2412,6 +2442,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -2450,6 +2482,8 @@
"filterTaggedLabel",
"albumTierVaults",
"keepScreenOnVideoPlayback",
"lengthUnitPixel",
"lengthUnitPercent",
"subtitlePositionTop",
"subtitlePositionBottom",
"vaultLockTypePin",
@ -2500,6 +2534,8 @@
"albumTierVaults",
"displayRefreshRatePreferHighest",
"displayRefreshRatePreferLowest",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"wallpaperTargetHome",
@ -2798,6 +2834,8 @@
"pl": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -2810,6 +2848,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -2835,6 +2875,8 @@
"ro": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -2848,6 +2890,8 @@
"filterLocatedLabel",
"filterTaggedLabel",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -2885,6 +2929,8 @@
"filterNoLocationLabel",
"albumTierVaults",
"coordinateDms",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"otherDirectoryDescription",
@ -3300,6 +3346,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -3642,6 +3690,8 @@
"chipActionCreateVault",
"chipActionConfigureVault",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -3665,6 +3715,8 @@
"uk": [
"chipActionGoToPlacePage",
"lengthUnitPixel",
"lengthUnitPercent",
"drawerPlacePage",
"placePageTitle",
"placeEmpty"
@ -3678,6 +3730,8 @@
"filterLocatedLabel",
"filterTaggedLabel",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",
@ -3714,6 +3768,8 @@
"filterLocatedLabel",
"filterTaggedLabel",
"albumTierVaults",
"lengthUnitPixel",
"lengthUnitPercent",
"vaultLockTypePin",
"vaultLockTypePassword",
"newVaultWarningDialogMessage",