motion photos: handle definition from Container namespace
This commit is contained in:
parent
db8f5c506c
commit
ee59b6ae73
20 changed files with 296 additions and 283 deletions
|
@ -17,6 +17,7 @@ import deckers.thibault.aves.metadata.Metadata
|
|||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ContentImageProvider
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
|
@ -157,18 +158,11 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
// which is returned as a second XMP directory
|
||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||
try {
|
||||
val pathParts = dataPropPath.split('/')
|
||||
|
||||
val embedBytes: ByteArray = if (pathParts.size == 1) {
|
||||
val propName = pathParts[0]
|
||||
val propNs = XMP.namespaceForPropPath(propName)
|
||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, propName) }.first { it != null }
|
||||
val embedBytes: ByteArray = if (!dataPropPath.contains('/')) {
|
||||
val propNs = XMP.namespaceForPropPath(dataPropPath)
|
||||
xmpDirs.map { it.xmpMeta.getPropertyBase64(propNs, dataPropPath) }.filterNotNull().first()
|
||||
} else {
|
||||
val structName = pathParts[0]
|
||||
val structNs = XMP.namespaceForPropPath(structName)
|
||||
val fieldName = pathParts[1]
|
||||
val fieldNs = XMP.namespaceForPropPath(fieldName)
|
||||
xmpDirs.map { it.xmpMeta.getStructField(structNs, structName, fieldNs, fieldName) }.first { it != null }.let {
|
||||
xmpDirs.map { it.xmpMeta.getSafeStructField(dataPropPath) }.filterNotNull().first().let {
|
||||
XMPUtils.decodeBase64(it.value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.util.Log
|
|||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeLong
|
||||
import deckers.thibault.aves.metadata.XMP.getSafeStructField
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
@ -140,7 +141,23 @@ object MultiPage {
|
|||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
var offsetFromEnd: Long? = null
|
||||
dir.xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
val xmpMeta = dir.xmpMeta
|
||||
if (xmpMeta.doesPropertyExist(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME)) {
|
||||
// GCamera motion photo
|
||||
xmpMeta.getSafeLong(XMP.GCAMERA_SCHEMA_NS, XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME) { offsetFromEnd = it }
|
||||
} else if (xmpMeta.doesPropertyExist(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
// Container motion photo
|
||||
val count = xmpMeta.countArrayItems(XMP.CONTAINER_SCHEMA_NS, XMP.CONTAINER_DIRECTORY_PROP_NAME)
|
||||
if (count == 2) {
|
||||
// expect the video to be the second item
|
||||
val i = 2
|
||||
val mime = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_MIME_PROP_NAME}")?.value
|
||||
val length = xmpMeta.getSafeStructField("${XMP.CONTAINER_DIRECTORY_PROP_NAME}[$i]/${XMP.CONTAINER_ITEM_PROP_NAME}/${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}")?.value
|
||||
if (MimeTypes.isVideo(mime) && length != null) {
|
||||
offsetFromEnd = length.toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
return offsetFromEnd
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ import android.util.Log
|
|||
import com.adobe.internal.xmp.XMPError
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMeta
|
||||
import com.adobe.internal.xmp.properties.XMPProperty
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import java.util.*
|
||||
|
||||
object XMP {
|
||||
|
@ -15,6 +17,15 @@ object XMP {
|
|||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||
private const val XMP_GIMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
|
||||
|
||||
// other namespaces
|
||||
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
||||
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
||||
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
||||
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
||||
|
||||
const val SUBJECT_PROP_NAME = "dc:subject"
|
||||
const val TITLE_PROP_NAME = "dc:title"
|
||||
|
@ -26,11 +37,13 @@ object XMP {
|
|||
private const val SPECIFIC_LANG = "en-US"
|
||||
|
||||
private val schemas = hashMapOf(
|
||||
"GAudio" to "http://ns.google.com/photos/1.0/audio/",
|
||||
"GDepth" to "http://ns.google.com/photos/1.0/depthmap/",
|
||||
"GImage" to "http://ns.google.com/photos/1.0/image/",
|
||||
"Container" to CONTAINER_SCHEMA_NS,
|
||||
"GAudio" to GAUDIO_SCHEMA_NS,
|
||||
"GDepth" to GDEPTH_SCHEMA_NS,
|
||||
"GImage" to GIMAGE_SCHEMA_NS,
|
||||
"Item" to CONTAINER_ITEM_SCHEMA_NS,
|
||||
"xmp" to XMP_SCHEMA_NS,
|
||||
"xmpGImg" to "http://ns.adobe.com/xap/1.0/g/img/",
|
||||
"xmpGImg" to XMP_GIMG_SCHEMA_NS,
|
||||
)
|
||||
|
||||
fun namespaceForPropPath(propPath: String) = schemas[propPath.split(":")[0]]
|
||||
|
@ -44,9 +57,11 @@ object XMP {
|
|||
|
||||
// motion photo
|
||||
|
||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||
|
||||
const val GCAMERA_VIDEO_OFFSET_PROP_NAME = "GCamera:MicroVideoOffset"
|
||||
const val CONTAINER_DIRECTORY_PROP_NAME = "Container:Directory"
|
||||
const val CONTAINER_ITEM_PROP_NAME = "Container:Item"
|
||||
const val CONTAINER_ITEM_LENGTH_PROP_NAME = "Item:Length"
|
||||
const val CONTAINER_ITEM_MIME_PROP_NAME = "Item:Mime"
|
||||
|
||||
// panorama
|
||||
// cf https://developers.google.com/streetview/spherical-metadata
|
||||
|
@ -79,7 +94,26 @@ object XMP {
|
|||
|
||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||
try {
|
||||
return doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)
|
||||
// GCamera motion photo
|
||||
if (doesPropertyExist(GCAMERA_SCHEMA_NS, GCAMERA_VIDEO_OFFSET_PROP_NAME)) return true
|
||||
|
||||
// Container motion photo
|
||||
if (doesPropertyExist(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)) {
|
||||
val count = countArrayItems(CONTAINER_SCHEMA_NS, CONTAINER_DIRECTORY_PROP_NAME)
|
||||
if (count == 2) {
|
||||
var hasImage = false
|
||||
var hasVideo = false
|
||||
for (i in 1 until count + 1) {
|
||||
val mime = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_MIME_PROP_NAME")?.value
|
||||
val length = getSafeStructField("$CONTAINER_DIRECTORY_PROP_NAME[$i]/$CONTAINER_ITEM_PROP_NAME/$CONTAINER_ITEM_LENGTH_PROP_NAME")?.value
|
||||
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||
}
|
||||
if (hasImage && hasVideo) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e: XMPException) {
|
||||
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||
// `BADSCHEMA` code is reported when we check a property
|
||||
|
@ -188,4 +222,21 @@ object XMP {
|
|||
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
|
||||
}
|
||||
}
|
||||
|
||||
// e.g. 'Container:Directory[42]/Container:Item/Item:Mime'
|
||||
fun XMPMeta.getSafeStructField(path: String): XMPProperty? {
|
||||
val separator = path.lastIndexOf("/")
|
||||
if (separator != -1) {
|
||||
val structName = path.substring(0, separator)
|
||||
val structNs = namespaceForPropPath(structName)
|
||||
val fieldName = path.substring(separator + 1)
|
||||
val fieldNs = namespaceForPropPath(fieldName)
|
||||
try {
|
||||
return getStructField(structNs, structName, fieldNs, fieldName)
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to get XMP struct field for path=$path", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -82,6 +82,8 @@
|
|||
"@entryActionShare": {},
|
||||
"entryActionViewSource": "View source",
|
||||
"@entryActionViewSource": {},
|
||||
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
|
||||
"@entryActionViewMotionPhotoVideo": {},
|
||||
"entryActionEdit": "Edit with…",
|
||||
"@entryActionEdit": {},
|
||||
"entryActionOpen": "Open with…",
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
"entryActionPrint": "인쇄",
|
||||
"entryActionShare": "공유",
|
||||
"entryActionViewSource": "소스 코드 보기",
|
||||
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
|
||||
"entryActionEdit": "편집…",
|
||||
"entryActionOpen": "다른 앱에서 열기…",
|
||||
"entryActionSetAs": "다음 용도로 사용…",
|
||||
|
|
|
@ -16,6 +16,8 @@ enum EntryAction {
|
|||
flip,
|
||||
// vector
|
||||
viewSource,
|
||||
// motion photo,
|
||||
viewMotionPhotoVideo,
|
||||
// external
|
||||
edit,
|
||||
open,
|
||||
|
@ -42,6 +44,7 @@ class EntryActions {
|
|||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.viewMotionPhotoVideo,
|
||||
EntryAction.rotateScreen,
|
||||
];
|
||||
|
||||
|
@ -87,6 +90,9 @@ extension ExtraEntryAction on EntryAction {
|
|||
// vector
|
||||
case EntryAction.viewSource:
|
||||
return context.l10n.entryActionViewSource;
|
||||
// motion photo
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return context.l10n.entryActionViewMotionPhotoVideo;
|
||||
// external
|
||||
case EntryAction.edit:
|
||||
return context.l10n.entryActionEdit;
|
||||
|
@ -132,6 +138,9 @@ extension ExtraEntryAction on EntryAction {
|
|||
// vector
|
||||
case EntryAction.viewSource:
|
||||
return AIcons.vector;
|
||||
// motion photo
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return AIcons.motionPhoto;
|
||||
// external
|
||||
case EntryAction.edit:
|
||||
case EntryAction.open:
|
||||
|
|
|
@ -38,7 +38,6 @@ class ViewerActionEditorPage extends StatelessWidget {
|
|||
EntryAction.export,
|
||||
EntryAction.print,
|
||||
EntryAction.rotateScreen,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.flip,
|
||||
EntryAction.rotateCCW,
|
||||
EntryAction.rotateCW,
|
||||
|
|
83
lib/widgets/viewer/embedded/embedded_data_opener.dart
Normal file
83
lib/widgets/viewer/embedded/embedded_data_opener.dart
Normal file
|
@ -0,0 +1,83 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin {
|
||||
final AvesEntry entry;
|
||||
final Widget child;
|
||||
|
||||
const EmbeddedDataOpener({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<OpenEmbeddedDataNotification>(
|
||||
onNotification: (notification) {
|
||||
_openEmbeddedData(context, notification);
|
||||
return true;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
||||
late Map fields;
|
||||
switch (notification.source) {
|
||||
case EmbeddedDataSource.motionPhotoVideo:
|
||||
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
|
||||
break;
|
||||
case EmbeddedDataSource.videoCover:
|
||||
fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
|
||||
break;
|
||||
case EmbeddedDataSource.xmp:
|
||||
fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
|
||||
break;
|
||||
}
|
||||
if (!fields.containsKey('mimeType') || !fields.containsKey('uri')) {
|
||||
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
|
||||
return;
|
||||
}
|
||||
|
||||
final mimeType = fields['mimeType']!;
|
||||
final uri = fields['uri']!;
|
||||
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
|
||||
// open with another app
|
||||
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
|
||||
if (!success) {
|
||||
// fallback to sharing, so that the file can be saved somewhere
|
||||
AndroidAppService.shareSingle(uri, mimeType).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
_openTempEntry(context, AvesEntry.fromMap(fields));
|
||||
}
|
||||
|
||||
void _openTempEntry(BuildContext context, AvesEntry tempEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
initialEntry: tempEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
37
lib/widgets/viewer/embedded/notifications.dart
Normal file
37
lib/widgets/viewer/embedded/notifications.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
|
||||
|
||||
class OpenEmbeddedDataNotification extends Notification {
|
||||
final EmbeddedDataSource source;
|
||||
final String? propPath;
|
||||
final String? mimeType;
|
||||
|
||||
const OpenEmbeddedDataNotification._private({
|
||||
required this.source,
|
||||
this.propPath,
|
||||
this.mimeType,
|
||||
});
|
||||
|
||||
factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.motionPhotoVideo,
|
||||
);
|
||||
|
||||
factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.videoCover,
|
||||
);
|
||||
|
||||
factory OpenEmbeddedDataNotification.xmp({
|
||||
required String propPath,
|
||||
required String mimeType,
|
||||
}) =>
|
||||
OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.xmp,
|
||||
propPath: propPath,
|
||||
mimeType: mimeType,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
|
@ -23,23 +23,15 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
|||
import 'package:aves/widgets/dialogs/rename_entry_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/viewer/debug/debug_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/printer.dart';
|
||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
final CollectionLens? collection;
|
||||
final VoidCallback showInfo;
|
||||
|
||||
EntryActionDelegate({
|
||||
required this.collection,
|
||||
required this.showInfo,
|
||||
});
|
||||
|
||||
void onActionSelected(BuildContext context, AvesEntry entry, EntryAction action) {
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
|
@ -52,7 +44,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
_showExportDialog(context, entry);
|
||||
break;
|
||||
case EntryAction.info:
|
||||
showInfo();
|
||||
ShowInfoNotification().dispatch(context);
|
||||
break;
|
||||
case EntryAction.rename:
|
||||
_showRenameDialog(context, entry);
|
||||
|
@ -100,6 +92,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
case EntryAction.viewSource:
|
||||
_goToSourceViewer(context, entry);
|
||||
break;
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||
break;
|
||||
case EntryAction.debug:
|
||||
_goToDebug(context, entry);
|
||||
break;
|
||||
|
@ -158,8 +153,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
if (!await entry.delete()) {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
} else {
|
||||
if (collection != null) {
|
||||
await collection!.source.removeEntries({entry.uri});
|
||||
final source = context.read<CollectionSource>();
|
||||
if (source.initialized) {
|
||||
await source.removeEntries({entry.uri});
|
||||
}
|
||||
EntryDeletedNotification(entry).dispatch(context);
|
||||
}
|
||||
|
@ -212,8 +208,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
onDone: (processed) {
|
||||
final movedOps = processed.where((e) => e.success);
|
||||
final movedCount = movedOps.length;
|
||||
final _collection = collection;
|
||||
final showAction = _collection != null && movedCount > 0
|
||||
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
final showAction = isMainMode && movedCount > 0
|
||||
? SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
|
|
|
@ -98,7 +98,7 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
)
|
||||
: const SizedBox();
|
||||
|
||||
final infoPage = NotificationListener<BackUpNotification>(
|
||||
final infoPage = NotificationListener<ShowImageNotification>(
|
||||
onNotification: (notification) {
|
||||
widget.onImagePageRequested();
|
||||
return true;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
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/highlight.dart';
|
||||
|
@ -12,8 +11,9 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
||||
import 'package:aves/widgets/viewer/hero.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
|
@ -49,7 +49,7 @@ class EntryViewerStack extends StatefulWidget {
|
|||
_EntryViewerStackState createState() => _EntryViewerStackState();
|
||||
}
|
||||
|
||||
class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final ValueNotifier<AvesEntry?> _entryNotifier = ValueNotifier(null);
|
||||
late int _currentHorizontalPage;
|
||||
late ValueNotifier<int> _currentVerticalPage;
|
||||
|
@ -60,7 +60,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
late Animation<double> _topOverlayScale, _bottomOverlayScale;
|
||||
late Animation<Offset> _bottomOverlayOffset;
|
||||
EdgeInsets? _frozenViewInsets, _frozenViewPadding;
|
||||
late EntryActionDelegate _entryActionDelegate;
|
||||
late VideoActionDelegate _videoActionDelegate;
|
||||
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
|
||||
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
|
||||
|
@ -109,10 +108,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
curve: Curves.easeOutQuad,
|
||||
));
|
||||
_overlayVisible.addListener(_onOverlayVisibleChange);
|
||||
_entryActionDelegate = EntryActionDelegate(
|
||||
collection: collection,
|
||||
showInfo: () => _goToVerticalPage(infoPage),
|
||||
);
|
||||
_videoActionDelegate = VideoActionDelegate(
|
||||
collection: collection,
|
||||
);
|
||||
|
@ -229,27 +224,22 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
builder: (context, mainEntry, child) {
|
||||
if (mainEntry == null) return const SizedBox.shrink();
|
||||
|
||||
return ViewerTopOverlay(
|
||||
return NotificationListener<ShowInfoNotification>(
|
||||
onNotification: (notification) {
|
||||
_goToVerticalPage(infoPage);
|
||||
return true;
|
||||
},
|
||||
child: EmbeddedDataOpener(
|
||||
entry: mainEntry,
|
||||
child: ViewerTopOverlay(
|
||||
mainEntry: mainEntry,
|
||||
scale: _topOverlayScale,
|
||||
canToggleFavourite: hasCollection,
|
||||
viewInsets: _frozenViewInsets,
|
||||
viewPadding: _frozenViewPadding,
|
||||
onActionSelected: (action) {
|
||||
var targetEntry = mainEntry;
|
||||
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
|
||||
if (pageEntry != null) {
|
||||
targetEntry = pageEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
_entryActionDelegate.onActionSelected(context, targetEntry, action);
|
||||
},
|
||||
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -421,7 +411,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
|||
void _onVerticalPageChanged(int page) {
|
||||
_currentVerticalPage.value = page;
|
||||
if (page == transitionPage) {
|
||||
_entryActionDelegate.dismissFeedback(context);
|
||||
dismissFeedback(context);
|
||||
_popVisual();
|
||||
} else if (page == infoPage) {
|
||||
// prevent hero when viewer is offscreen
|
||||
|
|
|
@ -3,9 +3,8 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/info/basic_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||
|
@ -47,11 +46,6 @@ class _InfoPageState extends State<InfoPage> {
|
|||
bottom: false,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleTopScroll,
|
||||
child: NotificationListener<OpenTempEntryNotification>(
|
||||
onNotification: (notification) {
|
||||
_openTempEntry(notification.entry);
|
||||
return true;
|
||||
},
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) {
|
||||
|
@ -59,13 +53,16 @@ class _InfoPageState extends State<InfoPage> {
|
|||
valueListenable: widget.entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
return entry != null
|
||||
? _InfoPageContent(
|
||||
? EmbeddedDataOpener(
|
||||
entry: entry,
|
||||
child: _InfoPageContent(
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
isScrollingNotifier: widget.isScrollingNotifier,
|
||||
scrollController: _scrollController,
|
||||
split: mqWidth > 600,
|
||||
goToViewer: _goToViewer,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
|
@ -75,7 +72,6 @@ class _InfoPageState extends State<InfoPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
);
|
||||
|
@ -102,25 +98,13 @@ class _InfoPageState extends State<InfoPage> {
|
|||
}
|
||||
|
||||
void _goToViewer() {
|
||||
BackUpNotification().dispatch(context);
|
||||
ShowImageNotification().dispatch(context);
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: Durations.viewerVerticalPageScrollAnimation,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _openTempEntry(AvesEntry tempEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
initialEntry: tempEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoPageContent extends StatefulWidget {
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -112,11 +110,8 @@ class InfoSearchDelegate extends SearchDelegate {
|
|||
icon: AIcons.info,
|
||||
text: context.l10n.viewerInfoSearchEmpty,
|
||||
)
|
||||
: NotificationListener<OpenTempEntryNotification>(
|
||||
onNotification: (notification) {
|
||||
_openTempEntry(context, notification.entry);
|
||||
return true;
|
||||
},
|
||||
: EmbeddedDataOpener(
|
||||
entry: entry,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) => tiles[index],
|
||||
|
@ -125,16 +120,4 @@ class InfoSearchDelegate extends SearchDelegate {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openTempEntry(BuildContext context, AvesEntry tempEntry) {
|
||||
Navigator.push(
|
||||
context,
|
||||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||
initialEntry: tempEntry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,27 +2,21 @@ import 'dart:collection';
|
|||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/source_viewer_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
||||
class MetadataDirTile extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
final String title;
|
||||
final MetadataDirectory dir;
|
||||
|
@ -45,9 +39,8 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
|||
if (tags.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final dirName = dir.name;
|
||||
Widget tile;
|
||||
if (dirName == MetadataDirectory.xmpDirectory) {
|
||||
tile = XmpDirTile(
|
||||
return XmpDirTile(
|
||||
entry: entry,
|
||||
tags: tags,
|
||||
expandedNotifier: expandedDirectoryNotifier,
|
||||
|
@ -64,7 +57,7 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
|||
break;
|
||||
}
|
||||
|
||||
tile = AvesExpansionTile(
|
||||
return AvesExpansionTile(
|
||||
title: title,
|
||||
color: dir.color ?? BrandColors.get(dirName) ?? stringToColor(dirName),
|
||||
expandedNotifier: expandedDirectoryNotifier,
|
||||
|
@ -82,13 +75,6 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
|||
],
|
||||
);
|
||||
}
|
||||
return NotificationListener<OpenEmbeddedDataNotification>(
|
||||
onNotification: (notification) {
|
||||
_openEmbeddedData(context, notification);
|
||||
return true;
|
||||
},
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
|
||||
|
@ -118,40 +104,4 @@ class MetadataDirTile extends StatelessWidget with FeedbackMixin {
|
|||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _openEmbeddedData(BuildContext context, OpenEmbeddedDataNotification notification) async {
|
||||
late Map fields;
|
||||
switch (notification.source) {
|
||||
case EmbeddedDataSource.motionPhotoVideo:
|
||||
fields = await embeddedDataService.extractMotionPhotoVideo(entry);
|
||||
break;
|
||||
case EmbeddedDataSource.videoCover:
|
||||
fields = await embeddedDataService.extractVideoEmbeddedPicture(entry);
|
||||
break;
|
||||
case EmbeddedDataSource.xmp:
|
||||
fields = await embeddedDataService.extractXmpDataProp(entry, notification.propPath, notification.mimeType);
|
||||
break;
|
||||
}
|
||||
if (!fields.containsKey('mimeType') || !fields.containsKey('uri')) {
|
||||
showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback);
|
||||
return;
|
||||
}
|
||||
|
||||
final mimeType = fields['mimeType']!;
|
||||
final uri = fields['uri']!;
|
||||
if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) {
|
||||
// open with another app
|
||||
unawaited(AndroidAppService.open(uri, mimeType).then((success) {
|
||||
if (!success) {
|
||||
// fallback to sharing, so that the file can be saved somewhere
|
||||
AndroidAppService.shareSingle(uri, mimeType).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,8 +29,6 @@ class XmpNamespace {
|
|||
return XmpExifNamespace(rawProps);
|
||||
case XmpGAudioNamespace.ns:
|
||||
return XmpGAudioNamespace(rawProps);
|
||||
case XmpGCameraNamespace.ns:
|
||||
return XmpGCameraNamespace(rawProps);
|
||||
case XmpGDepthNamespace.ns:
|
||||
return XmpGDepthNamespace(rawProps);
|
||||
case XmpGImageNamespace.ns:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
|
@ -70,35 +70,3 @@ class XmpGImageNamespace extends XmpGoogleNamespace {
|
|||
@override
|
||||
String get displayTitle => 'Google Image';
|
||||
}
|
||||
|
||||
class XmpGCameraNamespace extends XmpNamespace {
|
||||
static const ns = 'GCamera';
|
||||
static const videoOffsetKey = 'GCamera:MicroVideoOffset';
|
||||
static const videoDataKey = 'Data';
|
||||
|
||||
late bool _isMotionPhoto;
|
||||
|
||||
XmpGCameraNamespace(Map<String, String> rawProps) : super(ns, rawProps) {
|
||||
_isMotionPhoto = rawProps.keys.any((key) => key == videoOffsetKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, String> get buildProps {
|
||||
return _isMotionPhoto
|
||||
? Map.fromEntries({
|
||||
const MapEntry(videoDataKey, '[skipped]'),
|
||||
...rawProps.entries,
|
||||
})
|
||||
: rawProps;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) {
|
||||
return {
|
||||
videoDataKey: InfoLinkHandler(
|
||||
linkText: (context) => context.l10n.viewerInfoOpenLinkText,
|
||||
onTap: (context) => OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/viewer/embedded/notifications.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart';
|
||||
import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart';
|
||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class XmpBasicNamespace extends XmpNamespace {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class BackUpNotification extends Notification {}
|
||||
class ShowImageNotification extends Notification {}
|
||||
|
||||
class ShowInfoNotification extends Notification {}
|
||||
|
||||
class FilterSelectedNotification extends Notification {
|
||||
final CollectionFilter filter;
|
||||
|
@ -16,49 +17,3 @@ class EntryDeletedNotification extends Notification {
|
|||
|
||||
const EntryDeletedNotification(this.entry);
|
||||
}
|
||||
|
||||
class OpenTempEntryNotification extends Notification {
|
||||
final AvesEntry entry;
|
||||
|
||||
const OpenTempEntryNotification({
|
||||
required this.entry,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
||||
}
|
||||
|
||||
enum EmbeddedDataSource { motionPhotoVideo, videoCover, xmp }
|
||||
|
||||
class OpenEmbeddedDataNotification extends Notification {
|
||||
final EmbeddedDataSource source;
|
||||
final String? propPath;
|
||||
final String? mimeType;
|
||||
|
||||
const OpenEmbeddedDataNotification._private({
|
||||
required this.source,
|
||||
this.propPath,
|
||||
this.mimeType,
|
||||
});
|
||||
|
||||
factory OpenEmbeddedDataNotification.motionPhotoVideo() => const OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.motionPhotoVideo,
|
||||
);
|
||||
|
||||
factory OpenEmbeddedDataNotification.videoCover() => const OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.videoCover,
|
||||
);
|
||||
|
||||
factory OpenEmbeddedDataNotification.xmp({
|
||||
required String propPath,
|
||||
required String mimeType,
|
||||
}) =>
|
||||
OpenEmbeddedDataNotification._private(
|
||||
source: EmbeddedDataSource.xmp,
|
||||
propPath: propPath,
|
||||
mimeType: mimeType,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{source=$source, propPath=$propPath, mimeType=$mimeType}';
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/sweeper.dart';
|
||||
import 'package:aves/widgets/viewer/entry_action_delegate.dart';
|
||||
import 'package:aves/widgets/viewer/multipage/conductor.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/minimap.dart';
|
||||
|
@ -21,7 +22,6 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
final AvesEntry mainEntry;
|
||||
final Animation<double> scale;
|
||||
final EdgeInsets? viewInsets, viewPadding;
|
||||
final Function(EntryAction value) onActionSelected;
|
||||
final bool canToggleFavourite;
|
||||
final ValueNotifier<ViewState>? viewStateNotifier;
|
||||
|
||||
|
@ -35,7 +35,6 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
required this.canToggleFavourite,
|
||||
required this.viewInsets,
|
||||
required this.viewPadding,
|
||||
required this.onActionSelected,
|
||||
required this.viewStateNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -99,6 +98,8 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
return targetEntry.hasGps;
|
||||
case EntryAction.viewSource:
|
||||
return targetEntry.isSvg;
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
return targetEntry.isMotionPhoto;
|
||||
case EntryAction.rotateScreen:
|
||||
return settings.isRotationLocked;
|
||||
case EntryAction.share:
|
||||
|
@ -125,7 +126,6 @@ class ViewerTopOverlay extends StatelessWidget {
|
|||
scale: scale,
|
||||
mainEntry: mainEntry,
|
||||
pageEntry: pageEntry!,
|
||||
onActionSelected: onActionSelected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -153,7 +153,6 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
final List<EntryAction> quickActions, inAppActions, externalAppActions;
|
||||
final Animation<double> scale;
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final Function(EntryAction value) onActionSelected;
|
||||
|
||||
const _TopOverlayRow({
|
||||
Key? key,
|
||||
|
@ -163,7 +162,6 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
required this.scale,
|
||||
required this.mainEntry,
|
||||
required this.pageEntry,
|
||||
required this.onActionSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -192,7 +190,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
],
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action));
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
@ -202,7 +200,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
|
||||
Widget _buildOverlayButton(BuildContext context, EntryAction action) {
|
||||
Widget? child;
|
||||
void onPressed() => onActionSelected(action);
|
||||
void onPressed() => _onActionSelected(context, action);
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
child = _FavouriteToggler(
|
||||
|
@ -221,6 +219,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
case EntryAction.share:
|
||||
case EntryAction.rotateScreen:
|
||||
case EntryAction.viewSource:
|
||||
case EntryAction.viewMotionPhotoVideo:
|
||||
child = IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
onPressed: onPressed,
|
||||
|
@ -255,27 +254,9 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
isMenuItem: true,
|
||||
);
|
||||
break;
|
||||
case EntryAction.delete:
|
||||
case EntryAction.export:
|
||||
case EntryAction.flip:
|
||||
case EntryAction.info:
|
||||
case EntryAction.print:
|
||||
case EntryAction.rename:
|
||||
case EntryAction.rotateCCW:
|
||||
case EntryAction.rotateCW:
|
||||
case EntryAction.share:
|
||||
case EntryAction.rotateScreen:
|
||||
case EntryAction.viewSource:
|
||||
case EntryAction.debug:
|
||||
default:
|
||||
child = MenuRow(text: action.getText(context), icon: action.getIcon());
|
||||
break;
|
||||
// external app actions
|
||||
case EntryAction.edit:
|
||||
case EntryAction.open:
|
||||
case EntryAction.setAs:
|
||||
case EntryAction.openMap:
|
||||
child = Text(action.getText(context));
|
||||
break;
|
||||
}
|
||||
return PopupMenuItem(
|
||||
value: action,
|
||||
|
@ -316,6 +297,21 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onActionSelected(BuildContext context, EntryAction action) {
|
||||
var targetEntry = mainEntry;
|
||||
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) {
|
||||
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
|
||||
if (pageEntry != null) {
|
||||
targetEntry = pageEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntryActionDelegate().onActionSelected(context, targetEntry, action);
|
||||
}
|
||||
}
|
||||
|
||||
class _FavouriteToggler extends StatefulWidget {
|
||||
|
|
Loading…
Reference in a new issue