From 218db5d0919e374f996db03910f640c9ba42f5bf Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 25 Jan 2021 12:43:04 +0900 Subject: [PATCH] export: support embedded images --- .../aves/model/provider/ImageProvider.kt | 158 ++++++++++++++++- .../model/provider/MediaStoreImageProvider.kt | 147 ---------------- lib/main.dart | 46 ++--- lib/model/source/collection_source.dart | 2 + lib/model/source/media_store_source.dart | 1 + lib/widgets/collection/grid/thumbnail.dart | 4 +- .../common/action_mixins/feedback.dart | 2 +- .../common/providers/settings_provider.dart | 17 -- lib/widgets/home_page.dart | 23 +-- lib/widgets/viewer/entry_action_delegate.dart | 27 ++- ...oller.dart => entry_horizontal_pager.dart} | 0 lib/widgets/viewer/entry_vertical_pager.dart | 165 ++++++++++++++++++ lib/widgets/viewer/entry_viewer_page.dart | 43 ++--- lib/widgets/viewer/entry_viewer_stack.dart | 159 +---------------- lib/widgets/viewer/info/info_page.dart | 58 ++++-- lib/widgets/viewer/info/info_search.dart | 29 ++- .../viewer/info/metadata/xmp_tile.dart | 12 +- lib/widgets/viewer/info/notifications.dart | 13 ++ 18 files changed, 468 insertions(+), 438 deletions(-) delete mode 100644 lib/widgets/common/providers/settings_provider.dart rename lib/widgets/viewer/{entry_scroller.dart => entry_horizontal_pager.dart} (100%) create mode 100644 lib/widgets/viewer/entry_vertical_pager.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 49d944b59..4ff6a1ff8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -2,18 +2,27 @@ package deckers.thibault.aves.model.provider import android.content.ContentUris import android.content.Context +import android.graphics.Bitmap import android.media.MediaScannerConnection import android.net.Uri +import android.os.Build import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat +import deckers.thibault.aves.decoder.MultiTrackImage +import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp +import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.MimeTypes.isImage -import deckers.thibault.aves.utils.MimeTypes.isVideo +import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp +import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import java.io.File import java.io.FileNotFoundException @@ -36,8 +45,145 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException()) } - open suspend fun exportMultiple(context: Context, mimeType: String, destinationDir: String, entries: List, callback: ImageOpCallback) { - callback.onFailure(UnsupportedOperationException()) + suspend fun exportMultiple( + context: Context, + mimeType: String, + destinationDir: String, + entries: List, + callback: ImageOpCallback, + ) { + val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) + if (destinationDirDocFile == null) { + callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + return + } + + for (entry in entries) { + val sourceUri = entry.uri + val sourcePath = entry.path + val pageId = entry.pageId + + val result = hashMapOf( + "uri" to sourceUri.toString(), + "pageId" to pageId, + "success" to false, + ) + + try { + val newFields = exportSingleByTreeDocAndScan( + context = context, + sourceEntry = entry, + destinationDir = destinationDir, + destinationDirDocFile = destinationDirDocFile, + exportMimeType = mimeType, + ) + result["newFields"] = newFields + result["success"] = true + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e) + } + callback.onSuccess(result) + } + } + + private suspend fun exportSingleByTreeDocAndScan( + context: Context, + sourceEntry: AvesEntry, + destinationDir: String, + destinationDirDocFile: DocumentFileCompat, + exportMimeType: String, + ): FieldMap { + val sourceMimeType = sourceEntry.mimeType + val sourceUri = sourceEntry.uri + val pageId = sourceEntry.pageId + + var desiredNameWithoutExtension = if (sourceEntry.path != null) { + val sourcePath = sourceEntry.path + val sourceFile = File(sourcePath) + val sourceFileName = sourceFile.name + sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") + } else { + sourceUri.lastPathSegment!! + } + if (pageId != null) { + val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId + desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" + } + val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) { + MimeTypes.JPEG -> ".jpg" + MimeTypes.PNG -> ".png" + MimeTypes.WEBP -> ".webp" + else -> throw Exception("unsupported export MIME type=$exportMimeType") + } + + if (File(destinationDir, desiredFileName).exists()) { + throw Exception("file with name=$desiredFileName already exists in destination directory") + } + + // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` + // but in order to open an output stream to it, we need to use a `SingleDocumentFile` + // through a document URI, not a tree URI + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + @Suppress("BlockingMethodInNonBlockingContext") + val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) + val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) + + val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) { + MultiTrackImage(context, sourceUri, pageId) + } else if (sourceMimeType == MimeTypes.TIFF) { + TiffImage(context, sourceUri, pageId) + } else { + sourceUri + } + + // request a fresh image with the highest quality format + val glideOptions = RequestOptions() + .format(DecodeFormat.PREFER_ARGB_8888) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + + val target = Glide.with(context) + .asBitmap() + .apply(glideOptions) + .load(model) + .submit() + try { + @Suppress("BlockingMethodInNonBlockingContext") + var bitmap = target.get() + if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { + bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) + } + bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId") + + val quality = 100 + val format = when (exportMimeType) { + MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG + MimeTypes.PNG -> Bitmap.CompressFormat.PNG + MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (quality == 100) { + Bitmap.CompressFormat.WEBP_LOSSLESS + } else { + Bitmap.CompressFormat.WEBP_LOSSY + } + } else { + @Suppress("DEPRECATION") + Bitmap.CompressFormat.WEBP + } + else -> throw Exception("unsupported export MIME type=$exportMimeType") + } + + @Suppress("BlockingMethodInNonBlockingContext") + destinationDocFile.openOutputStream().use { + bitmap.compress(format, quality, it) + } + } finally { + Glide.with(context).clear(target) + } + + val fileName = destinationDocFile.name + val destinationFullPath = destinationDir + fileName + + return scanNewPath(context, destinationFullPath, exportMimeType) } suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { @@ -151,9 +297,9 @@ abstract class ImageProvider { // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") contentId = ContentUris.parseId(newUri) - if (isImage(mimeType)) { + if (MimeTypes.isImage(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) - } else if (isVideo(mimeType)) { + } else if (MimeTypes.isVideo(mimeType)) { contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index fc715a876..f06b5ea04 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -3,21 +3,13 @@ package deckers.thibault.aves.model.provider import android.annotation.SuppressLint import android.content.ContentUris import android.content.Context -import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.provider.MediaStore import android.util.Log -import com.bumptech.glide.Glide -import com.bumptech.glide.load.DecodeFormat -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.request.RequestOptions import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.decoder.MultiTrackImage -import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.SourceEntry -import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes.isImage @@ -319,145 +311,6 @@ class MediaStoreImageProvider : ImageProvider() { } } - override suspend fun exportMultiple( - context: Context, - mimeType: String, - destinationDir: String, - entries: List, - callback: ImageOpCallback, - ) { - val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) - return - } - - for (entry in entries) { - val sourceUri = entry.uri - val sourcePath = entry.path - val pageId = entry.pageId - - val result = hashMapOf( - "uri" to sourceUri.toString(), - "pageId" to pageId, - "success" to false, - ) - - if (sourcePath != null) { - try { - val newFields = exportSingleByTreeDocAndScan( - context = context, - sourceEntry = entry, - destinationDir = destinationDir, - destinationDirDocFile = destinationDirDocFile, - exportMimeType = mimeType, - ) - result["newFields"] = newFields - result["success"] = true - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e) - } - } - callback.onSuccess(result) - } - } - - private suspend fun exportSingleByTreeDocAndScan( - context: Context, - sourceEntry: AvesEntry, - destinationDir: String, - destinationDirDocFile: DocumentFileCompat, - exportMimeType: String, - ): FieldMap { - val sourceMimeType = sourceEntry.mimeType - val sourcePath = sourceEntry.path ?: throw Exception("source path is missing") - val sourceFile = File(sourcePath) - val pageId = sourceEntry.pageId - - val sourceFileName = sourceFile.name - var desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") - if (pageId != null) { - val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId - desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" - } - val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) { - MimeTypes.JPEG -> ".jpg" - MimeTypes.PNG -> ".png" - MimeTypes.WEBP -> ".webp" - else -> throw Exception("unsupported export MIME type=$exportMimeType") - } - - if (File(destinationDir, desiredFileName).exists()) { - throw Exception("file with name=$desiredFileName already exists in destination directory") - } - - // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` - // but in order to open an output stream to it, we need to use a `SingleDocumentFile` - // through a document URI, not a tree URI - // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - @Suppress("BlockingMethodInNonBlockingContext") - val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) - - val sourceUri = sourceEntry.uri - val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) { - MultiTrackImage(context, sourceUri, pageId) - } else if (sourceMimeType == MimeTypes.TIFF) { - TiffImage(context, sourceUri, pageId) - } else { - sourceUri - } - - // request a fresh image with the highest quality format - val glideOptions = RequestOptions() - .format(DecodeFormat.PREFER_ARGB_8888) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .skipMemoryCache(true) - - val target = Glide.with(context) - .asBitmap() - .apply(glideOptions) - .load(model) - .submit() - try { - @Suppress("BlockingMethodInNonBlockingContext") - var bitmap = target.get() - if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { - bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) - } - bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId") - - val quality = 100 - val format = when (exportMimeType) { - MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG - MimeTypes.PNG -> Bitmap.CompressFormat.PNG - MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (quality == 100) { - Bitmap.CompressFormat.WEBP_LOSSLESS - } else { - Bitmap.CompressFormat.WEBP_LOSSY - } - } else { - @Suppress("DEPRECATION") - Bitmap.CompressFormat.WEBP - } - else -> throw Exception("unsupported export MIME type=$exportMimeType") - } - - @Suppress("BlockingMethodInNonBlockingContext") - destinationDocFile.openOutputStream().use { - bitmap.compress(format, quality, it) - } - } finally { - Glide.with(context).clear(target) - } - - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName - - return scanNewPath(context, destinationFullPath, exportMimeType) - } - companion object { private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java) diff --git a/lib/main.dart b/lib/main.dart index 2445de98a..32927e63b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,11 @@ import 'dart:isolate'; import 'dart:ui'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; -import 'package:aves/widgets/common/providers/settings_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; @@ -16,6 +17,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:overlay_support/overlay_support.dart'; +import 'package:provider/provider.dart'; void main() { // HttpClient.enableTimelineLogging = true; // enable network traffic logging @@ -137,25 +139,29 @@ class _AvesAppState extends State { Widget build(BuildContext context) { // place the settings provider above `MaterialApp` // so it can be used during navigation transitions - return SettingsProvider( - child: OverlaySupport( - child: FutureBuilder( - future: _appSetup, - builder: (context, snapshot) { - final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) - ? getFirstPage() - : Scaffold( - body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), - ); - return MaterialApp( - navigatorKey: _navigatorKey, - home: home, - navigatorObservers: _navigatorObservers, - title: 'Aves', - darkTheme: darkTheme, - themeMode: ThemeMode.dark, - ); - }, + return ChangeNotifierProvider.value( + value: settings, + child: Provider( + create: (context) => MediaStoreSource(), + child: OverlaySupport( + child: FutureBuilder( + future: _appSetup, + builder: (context, snapshot) { + final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) + ? getFirstPage() + : Scaffold( + body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), + ); + return MaterialApp( + navigatorKey: _navigatorKey, + home: home, + navigatorObservers: _navigatorObservers, + title: 'Aves', + darkTheme: darkTheme, + themeMode: ThemeMode.dark, + ); + }, + ), ), ), ); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index c136ac722..c946af499 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -164,6 +164,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length); } + Future init(); + Future refresh(); Future refreshMetadata(Set entries); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 611db5974..6f93289e1 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -13,6 +13,7 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart'; import 'package:pedantic/pedantic.dart'; class MediaStoreSource extends CollectionSource { + @override Future init() async { final stopwatch = Stopwatch()..start(); stateNotifier.value = SourceState.loading; diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index c6b6a4d75..23d31c827 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -53,8 +53,8 @@ class InteractiveThumbnail extends StatelessWidget { Navigator.push( context, TransparentMaterialPageRoute( - settings: RouteSettings(name: MultiEntryViewerPage.routeName), - pageBuilder: (c, a, sa) => MultiEntryViewerPage( + settings: RouteSettings(name: EntryViewerPage.routeName), + pageBuilder: (c, a, sa) => EntryViewerPage( collection: collection, initialEntry: entry, ), diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 1e7c2374e..66156eae3 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -82,7 +82,7 @@ mixin FeedbackMixin { Future _hideOpReportOverlay() async { await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation); - _opReportOverlayEntry.remove(); + _opReportOverlayEntry?.remove(); _opReportOverlayEntry = null; } } diff --git a/lib/widgets/common/providers/settings_provider.dart b/lib/widgets/common/providers/settings_provider.dart deleted file mode 100644 index a47b5329b..000000000 --- a/lib/widgets/common/providers/settings_provider.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:aves/model/settings/settings.dart'; -import 'package:flutter/widgets.dart'; -import 'package:provider/provider.dart'; - -class SettingsProvider extends StatelessWidget { - final Widget child; - - const SettingsProvider({@required this.child}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: settings, - child: child, - ); - } -} diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 5d56a05b5..08346aa82 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -5,7 +5,7 @@ import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pedantic/pedantic.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; class HomePage extends StatefulWidget { static const routeName = '/'; @@ -34,7 +35,6 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - MediaStoreSource _mediaStore; AvesEntry _viewerEntry; String _shortcutRouteName; List _shortcutFilters; @@ -100,9 +100,9 @@ class _HomePageState extends State { unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', AvesApp.mode.toString())); if (AvesApp.mode != AppMode.view) { - _mediaStore = MediaStoreSource(); - await _mediaStore.init(); - unawaited(_mediaStore.refresh()); + final source = context.read(); + await source.init(); + unawaited(source.refresh()); } unawaited(Navigator.pushReplacement(context, _getRedirectRoute())); @@ -121,8 +121,10 @@ class _HomePageState extends State { Route _getRedirectRoute() { if (AvesApp.mode == AppMode.view) { return DirectMaterialPageRoute( - settings: RouteSettings(name: SingleEntryViewerPage.routeName), - builder: (_) => SingleEntryViewerPage(entry: _viewerEntry), + settings: RouteSettings(name: EntryViewerPage.routeName), + builder: (_) => EntryViewerPage( + initialEntry: _viewerEntry, + ), ); } @@ -134,15 +136,16 @@ class _HomePageState extends State { routeName = _shortcutRouteName ?? settings.homePage.routeName; filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson); } + final source = context.read(); switch (routeName) { case AlbumListPage.routeName: return DirectMaterialPageRoute( settings: RouteSettings(name: AlbumListPage.routeName), - builder: (_) => AlbumListPage(source: _mediaStore), + builder: (_) => AlbumListPage(source: source), ); case SearchPage.routeName: return SearchPageRoute( - delegate: CollectionSearchDelegate(source: _mediaStore), + delegate: CollectionSearchDelegate(source: source), ); case CollectionPage.routeName: default: @@ -150,7 +153,7 @@ class _HomePageState extends State { settings: RouteSettings(name: CollectionPage.routeName), builder: (_) => CollectionPage( CollectionLens( - source: _mediaStore, + source: source, filters: filters, groupFactor: settings.collectionGroupFactor, sortFactor: settings.collectionSortFactor, diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index faaeec50b..d46f09280 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -4,6 +4,7 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_op_events.dart'; @@ -22,6 +23,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:pedantic/pedantic.dart'; +import 'package:provider/provider.dart'; class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionLens collection; @@ -150,19 +152,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } Future _showExportDialog(BuildContext context, AvesEntry entry) async { - String destinationAlbum; - if (hasCollection) { - final source = collection.source; - destinationAlbum = await Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: AlbumPickPage.routeName), - builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export), - ), - ); - } else { - destinationAlbum = entry.directory; - } + final source = context.read(); + final destinationAlbum = await Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: AlbumPickPage.routeName), + builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export), + ), + ); if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; @@ -198,9 +195,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } else { showFeedback(context, 'Done!'); } - if (hasCollection) { - collection.source.refresh(); - } + source.refresh(); }, ); } diff --git a/lib/widgets/viewer/entry_scroller.dart b/lib/widgets/viewer/entry_horizontal_pager.dart similarity index 100% rename from lib/widgets/viewer/entry_scroller.dart rename to lib/widgets/viewer/entry_horizontal_pager.dart diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart new file mode 100644 index 000000000..95391d6a9 --- /dev/null +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -0,0 +1,165 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; +import 'package:aves/widgets/viewer/entry_horizontal_pager.dart'; +import 'package:aves/widgets/viewer/info/info_page.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/multipage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; +import 'package:tuple/tuple.dart'; + +class ViewerVerticalPageView extends StatefulWidget { + final CollectionLens collection; + final ValueNotifier entryNotifier; + final List> videoControllers; + final List> multiPageControllers; + final PageController horizontalPager, verticalPager; + final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; + final VoidCallback onImageTap, onImagePageRequested; + final void Function(String uri) onViewDisposed; + + const ViewerVerticalPageView({ + @required this.collection, + @required this.entryNotifier, + @required this.videoControllers, + @required this.multiPageControllers, + @required this.verticalPager, + @required this.horizontalPager, + @required this.onVerticalPageChanged, + @required this.onHorizontalPageChanged, + @required this.onImageTap, + @required this.onImagePageRequested, + @required this.onViewDisposed, + }); + + @override + _ViewerVerticalPageViewState createState() => _ViewerVerticalPageViewState(); +} + +class _ViewerVerticalPageViewState extends State { + final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); + final ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); + AvesEntry _oldEntry; + + CollectionLens get collection => widget.collection; + + bool get hasCollection => collection != null; + + AvesEntry get entry => widget.entryNotifier.value; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant ViewerVerticalPageView oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(ViewerVerticalPageView widget) { + widget.verticalPager.addListener(_onVerticalPageControllerChanged); + widget.entryNotifier.addListener(_onEntryChanged); + if (_oldEntry != entry) _onEntryChanged(); + } + + void _unregisterWidget(ViewerVerticalPageView widget) { + widget.verticalPager.removeListener(_onVerticalPageControllerChanged); + widget.entryNotifier.removeListener(_onEntryChanged); + _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); + } + + @override + Widget build(BuildContext context) { + final pages = [ + // fake page for opacity transition between collection and viewer + SizedBox(), + hasCollection + ? MultiEntryScroller( + collection: collection, + pageController: widget.horizontalPager, + onTap: widget.onImageTap, + onPageChanged: widget.onHorizontalPageChanged, + videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, + onViewDisposed: widget.onViewDisposed, + ) + : SingleEntryScroller( + entry: entry, + onTap: widget.onImageTap, + videoControllers: widget.videoControllers, + multiPageControllers: widget.multiPageControllers, + ), + NotificationListener( + onNotification: (notification) { + if (notification is BackUpNotification) widget.onImagePageRequested(); + return false; + }, + child: InfoPage( + collection: collection, + entryNotifier: widget.entryNotifier, + visibleNotifier: _infoPageVisibleNotifier, + ), + ), + ]; + return ValueListenableBuilder( + valueListenable: _backgroundColorNotifier, + builder: (context, backgroundColor, child) => Container( + color: backgroundColor, + child: child, + ), + child: PageView( + key: Key('vertical-pageview'), + scrollDirection: Axis.vertical, + controller: widget.verticalPager, + physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()), + onPageChanged: (page) { + widget.onVerticalPageChanged(page); + _infoPageVisibleNotifier.value = page == pages.length - 1; + }, + children: pages, + ), + ); + } + + void _onVerticalPageControllerChanged() { + final opacity = min(1.0, widget.verticalPager.page); + _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity); + } + + // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) + void _onEntryChanged() { + _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); + _oldEntry = entry; + + if (entry != null) { + entry.imageChangeNotifier.addListener(_onImageChanged); + // make sure to locate the entry, + // so that we can display the address instead of coordinates + // even when background locating has not reached this entry yet + entry.locate(); + } else { + Navigator.pop(context); + } + } + + // when the entry image itself changed (e.g. after rotation) + void _onImageChanged() async { + // rebuild to refresh the Image inside ImagePage + setState(() {}); + } +} diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 5ee08cdd0..8bb27913c 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -4,50 +4,33 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; import 'package:flutter/material.dart'; -class MultiEntryViewerPage extends AnimatedWidget { +class EntryViewerPage extends StatelessWidget { static const routeName = '/viewer'; final CollectionLens collection; final AvesEntry initialEntry; - const MultiEntryViewerPage({ + const EntryViewerPage({ Key key, this.collection, this.initialEntry, - }) : super(key: key, listenable: collection); - - @override - Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Scaffold( - body: EntryViewerStack( - collection: collection, - initialEntry: initialEntry, - ), - backgroundColor: Colors.transparent, - resizeToAvoidBottomInset: false, - ), - ); - } -} - -class SingleEntryViewerPage extends StatelessWidget { - static const routeName = '/viewer'; - - final AvesEntry entry; - - const SingleEntryViewerPage({ - Key key, - this.entry, }) : super(key: key); @override Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: EntryViewerStack( - initialEntry: entry, - ), + body: collection != null + ? AnimatedBuilder( + animation: collection, + builder: (context, child) => EntryViewerStack( + collection: collection, + initialEntry: initialEntry, + ), + ) + : EntryViewerStack( + initialEntry: initialEntry, + ), backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, resizeToAvoidBottomInset: false, ), diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 4ff471002..b9867800f 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,7 +1,7 @@ import 'dart:math'; -import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -9,10 +9,8 @@ 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/basic/insets.dart'; -import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/viewer/entry_action_delegate.dart'; -import 'package:aves/widgets/viewer/entry_scroller.dart'; -import 'package:aves/widgets/viewer/info/info_page.dart'; +import 'package:aves/widgets/viewer/entry_vertical_pager.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/overlay/bottom.dart'; @@ -36,7 +34,7 @@ class EntryViewerStack extends StatefulWidget { const EntryViewerStack({ Key key, this.collection, - this.initialEntry, + @required this.initialEntry, }) : super(key: key); @override @@ -475,154 +473,3 @@ class _EntryViewerStackState extends State with SingleTickerPr void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); } - -class ViewerVerticalPageView extends StatefulWidget { - final CollectionLens collection; - final ValueNotifier entryNotifier; - final List> videoControllers; - final List> multiPageControllers; - final PageController horizontalPager, verticalPager; - final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; - final VoidCallback onImageTap, onImagePageRequested; - final void Function(String uri) onViewDisposed; - - const ViewerVerticalPageView({ - @required this.collection, - @required this.entryNotifier, - @required this.videoControllers, - @required this.multiPageControllers, - @required this.verticalPager, - @required this.horizontalPager, - @required this.onVerticalPageChanged, - @required this.onHorizontalPageChanged, - @required this.onImageTap, - @required this.onImagePageRequested, - @required this.onViewDisposed, - }); - - @override - _ViewerVerticalPageViewState createState() => _ViewerVerticalPageViewState(); -} - -class _ViewerVerticalPageViewState extends State { - final ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); - final ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); - AvesEntry _oldEntry; - - CollectionLens get collection => widget.collection; - - bool get hasCollection => collection != null; - - AvesEntry get entry => widget.entryNotifier.value; - - @override - void initState() { - super.initState(); - _registerWidget(widget); - } - - @override - void didUpdateWidget(covariant ViewerVerticalPageView oldWidget) { - super.didUpdateWidget(oldWidget); - _unregisterWidget(oldWidget); - _registerWidget(widget); - } - - @override - void dispose() { - _unregisterWidget(widget); - super.dispose(); - } - - void _registerWidget(ViewerVerticalPageView widget) { - widget.verticalPager.addListener(_onVerticalPageControllerChanged); - widget.entryNotifier.addListener(_onEntryChanged); - if (_oldEntry != entry) _onEntryChanged(); - } - - void _unregisterWidget(ViewerVerticalPageView widget) { - widget.verticalPager.removeListener(_onVerticalPageControllerChanged); - widget.entryNotifier.removeListener(_onEntryChanged); - _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); - } - - @override - Widget build(BuildContext context) { - final pages = [ - // fake page for opacity transition between collection and viewer - SizedBox(), - hasCollection - ? MultiEntryScroller( - collection: collection, - pageController: widget.horizontalPager, - onTap: widget.onImageTap, - onPageChanged: widget.onHorizontalPageChanged, - videoControllers: widget.videoControllers, - multiPageControllers: widget.multiPageControllers, - onViewDisposed: widget.onViewDisposed, - ) - : SingleEntryScroller( - entry: entry, - onTap: widget.onImageTap, - videoControllers: widget.videoControllers, - multiPageControllers: widget.multiPageControllers, - ), - NotificationListener( - onNotification: (notification) { - if (notification is BackUpNotification) widget.onImagePageRequested(); - return false; - }, - child: InfoPage( - collection: collection, - entryNotifier: widget.entryNotifier, - visibleNotifier: _infoPageVisibleNotifier, - ), - ), - ]; - return ValueListenableBuilder( - valueListenable: _backgroundColorNotifier, - builder: (context, backgroundColor, child) => Container( - color: backgroundColor, - child: child, - ), - child: PageView( - key: Key('vertical-pageview'), - scrollDirection: Axis.vertical, - controller: widget.verticalPager, - physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()), - onPageChanged: (page) { - widget.onVerticalPageChanged(page); - _infoPageVisibleNotifier.value = page == pages.length - 1; - }, - children: pages, - ), - ); - } - - void _onVerticalPageControllerChanged() { - final opacity = min(1.0, widget.verticalPager.page); - _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity); - } - - // when the entry changed (e.g. by scrolling through the PageView, or if the entry got deleted) - void _onEntryChanged() { - _oldEntry?.imageChangeNotifier?.removeListener(_onImageChanged); - _oldEntry = entry; - - if (entry != null) { - entry.imageChangeNotifier.addListener(_onImageChanged); - // make sure to locate the entry, - // so that we can display the address instead of coordinates - // even when background locating has not reached this entry yet - entry.locate(); - } else { - Navigator.pop(context); - } - } - - // when the entry image itself changed (e.g. after rotation) - void _onImageChanged() async { - // rebuild to refresh the Image inside ImagePage - setState(() {}); - } -} diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 87b9afe34..b84e2a4d7 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -1,9 +1,11 @@ -import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/entry.dart'; +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/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; @@ -45,25 +47,31 @@ class _InfoPageState extends State { bottom: false, child: NotificationListener( onNotification: _handleTopScroll, - child: Selector( - selector: (c, mq) => mq.size.width, - builder: (c, mqWidth, child) { - return ValueListenableBuilder( - valueListenable: widget.entryNotifier, - builder: (context, entry, child) { - return entry != null - ? _InfoPageContent( - collection: collection, - entry: entry, - visibleNotifier: widget.visibleNotifier, - scrollController: _scrollController, - split: mqWidth > 400, - goToViewer: _goToViewer, - ) - : SizedBox.shrink(); - }, - ); + child: NotificationListener( + onNotification: (notification) { + _openTempEntry(notification.entry); + return true; }, + child: Selector( + selector: (c, mq) => mq.size.width, + builder: (c, mqWidth, child) { + return ValueListenableBuilder( + valueListenable: widget.entryNotifier, + builder: (context, entry, child) { + return entry != null + ? _InfoPageContent( + collection: collection, + entry: entry, + visibleNotifier: widget.visibleNotifier, + scrollController: _scrollController, + split: mqWidth > 400, + goToViewer: _goToViewer, + ) + : SizedBox.shrink(); + }, + ); + }, + ), ), ), ), @@ -103,6 +111,18 @@ class _InfoPageState extends State { curve: Curves.easeInOut, ); } + + void _openTempEntry(AvesEntry tempEntry) { + Navigator.push( + context, + TransparentMaterialPageRoute( + settings: RouteSettings(name: EntryViewerPage.routeName), + pageBuilder: (c, a, sa) => EntryViewerPage( + initialEntry: tempEntry, + ), + ), + ); + } } class _InfoPageContent extends StatefulWidget { diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 04744e277..601f6b70c 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -1,8 +1,11 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/viewer/entry_viewer_page.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'; @@ -109,10 +112,28 @@ class InfoSearchDelegate extends SearchDelegate { icon: AIcons.info, text: 'No matching keys', ) - : ListView.builder( - padding: EdgeInsets.all(8), - itemBuilder: (context, index) => tiles[index], - itemCount: tiles.length, + : NotificationListener( + onNotification: (notification) { + _openTempEntry(context, notification.entry); + return true; + }, + child: ListView.builder( + padding: EdgeInsets.all(8), + itemBuilder: (context, index) => tiles[index], + itemCount: tiles.length, + ), ); } + + void _openTempEntry(BuildContext context, AvesEntry tempEntry) { + Navigator.push( + context, + TransparentMaterialPageRoute( + settings: RouteSettings(name: EntryViewerPage.routeName), + pageBuilder: (c, a, sa) => EntryViewerPage( + initialEntry: tempEntry, + ), + ), + ); + } } diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 62098f1f3..731d49867 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -6,10 +6,8 @@ import 'package:aves/ref/xmp.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; -import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.dart'; @@ -17,6 +15,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_ns/iptc.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart'; +import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; @@ -123,13 +122,6 @@ class _XmpDirTileState extends State with FeedbackMixin { return; } - final embedEntry = AvesEntry.fromMap(fields); - unawaited(Navigator.push( - context, - TransparentMaterialPageRoute( - settings: RouteSettings(name: SingleEntryViewerPage.routeName), - pageBuilder: (c, a, sa) => SingleEntryViewerPage(entry: embedEntry), - ), - )); + OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context); } } diff --git a/lib/widgets/viewer/info/notifications.dart b/lib/widgets/viewer/info/notifications.dart index 0f46e5aac..32afe7ae9 100644 --- a/lib/widgets/viewer/info/notifications.dart +++ b/lib/widgets/viewer/info/notifications.dart @@ -1,4 +1,6 @@ +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class BackUpNotification extends Notification {} @@ -8,3 +10,14 @@ class FilterNotification extends Notification { const FilterNotification(this.filter); } + +class OpenTempEntryNotification extends Notification { + final AvesEntry entry; + + const OpenTempEntryNotification({ + @required this.entry, + }); + + @override + String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}'; +}