export: support embedded images
This commit is contained in:
parent
c4fdd38850
commit
218db5d091
18 changed files with 468 additions and 438 deletions
|
@ -2,18 +2,27 @@ package deckers.thibault.aves.model.provider
|
||||||
|
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
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 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.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
|
||||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
@ -36,8 +45,145 @@ abstract class ImageProvider {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
open suspend fun exportMultiple(context: Context, mimeType: String, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
|
suspend fun exportMultiple(
|
||||||
callback.onFailure(UnsupportedOperationException())
|
context: Context,
|
||||||
|
mimeType: String,
|
||||||
|
destinationDir: String,
|
||||||
|
entries: List<AvesEntry>,
|
||||||
|
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<String, Any?>(
|
||||||
|
"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) {
|
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")
|
// `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")
|
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||||
contentId = ContentUris.parseId(newUri)
|
contentId = ContentUris.parseId(newUri)
|
||||||
if (isImage(mimeType)) {
|
if (MimeTypes.isImage(mimeType)) {
|
||||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
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)
|
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,21 +3,13 @@ package deckers.thibault.aves.model.provider
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
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 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.AvesEntry
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
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<AvesEntry>,
|
|
||||||
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<String, Any?>(
|
|
||||||
"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 {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
|
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,11 @@ import 'dart:isolate';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
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/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.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/home_page.dart';
|
||||||
import 'package:aves/widgets/welcome_page.dart';
|
import 'package:aves/widgets/welcome_page.dart';
|
||||||
import 'package:firebase_analytics/firebase_analytics.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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:overlay_support/overlay_support.dart';
|
import 'package:overlay_support/overlay_support.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||||
|
@ -137,7 +139,10 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// place the settings provider above `MaterialApp`
|
// place the settings provider above `MaterialApp`
|
||||||
// so it can be used during navigation transitions
|
// so it can be used during navigation transitions
|
||||||
return SettingsProvider(
|
return ChangeNotifierProvider<Settings>.value(
|
||||||
|
value: settings,
|
||||||
|
child: Provider<CollectionSource>(
|
||||||
|
create: (context) => MediaStoreSource(),
|
||||||
child: OverlaySupport(
|
child: OverlaySupport(
|
||||||
child: FutureBuilder<void>(
|
child: FutureBuilder<void>(
|
||||||
future: _appSetup,
|
future: _appSetup,
|
||||||
|
@ -158,6 +163,7 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -164,6 +164,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> init();
|
||||||
|
|
||||||
Future<void> refresh();
|
Future<void> refresh();
|
||||||
|
|
||||||
Future<void> refreshMetadata(Set<AvesEntry> entries);
|
Future<void> refreshMetadata(Set<AvesEntry> entries);
|
||||||
|
|
|
@ -13,6 +13,7 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
class MediaStoreSource extends CollectionSource {
|
class MediaStoreSource extends CollectionSource {
|
||||||
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
|
|
|
@ -53,8 +53,8 @@ class InteractiveThumbnail extends StatelessWidget {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
TransparentMaterialPageRoute(
|
TransparentMaterialPageRoute(
|
||||||
settings: RouteSettings(name: MultiEntryViewerPage.routeName),
|
settings: RouteSettings(name: EntryViewerPage.routeName),
|
||||||
pageBuilder: (c, a, sa) => MultiEntryViewerPage(
|
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||||
collection: collection,
|
collection: collection,
|
||||||
initialEntry: entry,
|
initialEntry: entry,
|
||||||
),
|
),
|
||||||
|
|
|
@ -82,7 +82,7 @@ mixin FeedbackMixin {
|
||||||
|
|
||||||
Future<void> _hideOpReportOverlay() async {
|
Future<void> _hideOpReportOverlay() async {
|
||||||
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
|
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
|
||||||
_opReportOverlayEntry.remove();
|
_opReportOverlayEntry?.remove();
|
||||||
_opReportOverlayEntry = null;
|
_opReportOverlayEntry = 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<Settings>.value(
|
|
||||||
value: settings,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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/screen_on.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.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/image_file_service.dart';
|
||||||
import 'package:aves/services/viewer_service.dart';
|
import 'package:aves/services/viewer_service.dart';
|
||||||
import 'package:aves/utils/android_file_utils.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:flutter/services.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
static const routeName = '/';
|
static const routeName = '/';
|
||||||
|
@ -34,7 +35,6 @@ class HomePage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
MediaStoreSource _mediaStore;
|
|
||||||
AvesEntry _viewerEntry;
|
AvesEntry _viewerEntry;
|
||||||
String _shortcutRouteName;
|
String _shortcutRouteName;
|
||||||
List<String> _shortcutFilters;
|
List<String> _shortcutFilters;
|
||||||
|
@ -100,9 +100,9 @@ class _HomePageState extends State<HomePage> {
|
||||||
unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', AvesApp.mode.toString()));
|
unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', AvesApp.mode.toString()));
|
||||||
|
|
||||||
if (AvesApp.mode != AppMode.view) {
|
if (AvesApp.mode != AppMode.view) {
|
||||||
_mediaStore = MediaStoreSource();
|
final source = context.read<CollectionSource>();
|
||||||
await _mediaStore.init();
|
await source.init();
|
||||||
unawaited(_mediaStore.refresh());
|
unawaited(source.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(Navigator.pushReplacement(context, _getRedirectRoute()));
|
unawaited(Navigator.pushReplacement(context, _getRedirectRoute()));
|
||||||
|
@ -121,8 +121,10 @@ class _HomePageState extends State<HomePage> {
|
||||||
Route _getRedirectRoute() {
|
Route _getRedirectRoute() {
|
||||||
if (AvesApp.mode == AppMode.view) {
|
if (AvesApp.mode == AppMode.view) {
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: RouteSettings(name: SingleEntryViewerPage.routeName),
|
settings: RouteSettings(name: EntryViewerPage.routeName),
|
||||||
builder: (_) => SingleEntryViewerPage(entry: _viewerEntry),
|
builder: (_) => EntryViewerPage(
|
||||||
|
initialEntry: _viewerEntry,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,15 +136,16 @@ class _HomePageState extends State<HomePage> {
|
||||||
routeName = _shortcutRouteName ?? settings.homePage.routeName;
|
routeName = _shortcutRouteName ?? settings.homePage.routeName;
|
||||||
filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson);
|
filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson);
|
||||||
}
|
}
|
||||||
|
final source = context.read<CollectionSource>();
|
||||||
switch (routeName) {
|
switch (routeName) {
|
||||||
case AlbumListPage.routeName:
|
case AlbumListPage.routeName:
|
||||||
return DirectMaterialPageRoute(
|
return DirectMaterialPageRoute(
|
||||||
settings: RouteSettings(name: AlbumListPage.routeName),
|
settings: RouteSettings(name: AlbumListPage.routeName),
|
||||||
builder: (_) => AlbumListPage(source: _mediaStore),
|
builder: (_) => AlbumListPage(source: source),
|
||||||
);
|
);
|
||||||
case SearchPage.routeName:
|
case SearchPage.routeName:
|
||||||
return SearchPageRoute(
|
return SearchPageRoute(
|
||||||
delegate: CollectionSearchDelegate(source: _mediaStore),
|
delegate: CollectionSearchDelegate(source: source),
|
||||||
);
|
);
|
||||||
case CollectionPage.routeName:
|
case CollectionPage.routeName:
|
||||||
default:
|
default:
|
||||||
|
@ -150,7 +153,7 @@ class _HomePageState extends State<HomePage> {
|
||||||
settings: RouteSettings(name: CollectionPage.routeName),
|
settings: RouteSettings(name: CollectionPage.routeName),
|
||||||
builder: (_) => CollectionPage(
|
builder: (_) => CollectionPage(
|
||||||
CollectionLens(
|
CollectionLens(
|
||||||
source: _mediaStore,
|
source: source,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
groupFactor: settings.collectionGroupFactor,
|
groupFactor: settings.collectionGroupFactor,
|
||||||
sortFactor: settings.collectionSortFactor,
|
sortFactor: settings.collectionSortFactor,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.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/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/image_op_events.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:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
|
@ -150,19 +152,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showExportDialog(BuildContext context, AvesEntry entry) async {
|
Future<void> _showExportDialog(BuildContext context, AvesEntry entry) async {
|
||||||
String destinationAlbum;
|
final source = context.read<CollectionSource>();
|
||||||
if (hasCollection) {
|
final destinationAlbum = await Navigator.push(
|
||||||
final source = collection.source;
|
|
||||||
destinationAlbum = await Navigator.push(
|
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String>(
|
MaterialPageRoute<String>(
|
||||||
settings: RouteSettings(name: AlbumPickPage.routeName),
|
settings: RouteSettings(name: AlbumPickPage.routeName),
|
||||||
builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export),
|
builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
destinationAlbum = entry.directory;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
||||||
|
@ -198,9 +195,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
||||||
} else {
|
} else {
|
||||||
showFeedback(context, 'Done!');
|
showFeedback(context, 'Done!');
|
||||||
}
|
}
|
||||||
if (hasCollection) {
|
source.refresh();
|
||||||
collection.source.refresh();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
165
lib/widgets/viewer/entry_vertical_pager.dart
Normal file
165
lib/widgets/viewer/entry_vertical_pager.dart
Normal file
|
@ -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<AvesEntry> entryNotifier;
|
||||||
|
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
||||||
|
final List<Tuple2<String, MultiPageController>> 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<ViewerVerticalPageView> {
|
||||||
|
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
|
||||||
|
final ValueNotifier<bool> _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<Color>(
|
||||||
|
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(() {});
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,49 +4,32 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
|
import 'package:aves/widgets/viewer/entry_viewer_stack.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class MultiEntryViewerPage extends AnimatedWidget {
|
class EntryViewerPage extends StatelessWidget {
|
||||||
static const routeName = '/viewer';
|
static const routeName = '/viewer';
|
||||||
|
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final AvesEntry initialEntry;
|
final AvesEntry initialEntry;
|
||||||
|
|
||||||
const MultiEntryViewerPage({
|
const EntryViewerPage({
|
||||||
Key key,
|
Key key,
|
||||||
this.collection,
|
this.collection,
|
||||||
this.initialEntry,
|
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);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MediaQueryDataProvider(
|
return MediaQueryDataProvider(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: EntryViewerStack(
|
body: collection != null
|
||||||
initialEntry: entry,
|
? AnimatedBuilder(
|
||||||
|
animation: collection,
|
||||||
|
builder: (context, child) => EntryViewerStack(
|
||||||
|
collection: collection,
|
||||||
|
initialEntry: initialEntry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: EntryViewerStack(
|
||||||
|
initialEntry: initialEntry,
|
||||||
),
|
),
|
||||||
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/filters/filters.dart';
|
|
||||||
import 'package:aves/model/entry.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/screen_on.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.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/utils/change_notifier.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.dart';
|
import 'package:aves/widgets/collection/collection_page.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.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_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/entry_scroller.dart';
|
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_page.dart';
|
|
||||||
import 'package:aves/widgets/viewer/info/notifications.dart';
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:aves/widgets/viewer/multipage.dart';
|
import 'package:aves/widgets/viewer/multipage.dart';
|
||||||
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
import 'package:aves/widgets/viewer/overlay/bottom.dart';
|
||||||
|
@ -36,7 +34,7 @@ class EntryViewerStack extends StatefulWidget {
|
||||||
const EntryViewerStack({
|
const EntryViewerStack({
|
||||||
Key key,
|
Key key,
|
||||||
this.collection,
|
this.collection,
|
||||||
this.initialEntry,
|
@required this.initialEntry,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -475,154 +473,3 @@ class _EntryViewerStackState extends State<EntryViewerStack> with SingleTickerPr
|
||||||
|
|
||||||
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
|
void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause());
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerVerticalPageView extends StatefulWidget {
|
|
||||||
final CollectionLens collection;
|
|
||||||
final ValueNotifier<AvesEntry> entryNotifier;
|
|
||||||
final List<Tuple2<String, IjkMediaController>> videoControllers;
|
|
||||||
final List<Tuple2<String, MultiPageController>> 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<ViewerVerticalPageView> {
|
|
||||||
final ValueNotifier<Color> _backgroundColorNotifier = ValueNotifier(Colors.black);
|
|
||||||
final ValueNotifier<bool> _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<Color>(
|
|
||||||
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(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
|
||||||
import 'package:aves/model/entry.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/model/source/collection_lens.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.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/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/basic_section.dart';
|
||||||
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
import 'package:aves/widgets/viewer/info/info_app_bar.dart';
|
||||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
import 'package:aves/widgets/viewer/info/location_section.dart';
|
||||||
|
@ -45,6 +47,11 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: NotificationListener(
|
child: NotificationListener(
|
||||||
onNotification: _handleTopScroll,
|
onNotification: _handleTopScroll,
|
||||||
|
child: NotificationListener<OpenTempEntryNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
_openTempEntry(notification.entry);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
child: Selector<MediaQueryData, double>(
|
child: Selector<MediaQueryData, double>(
|
||||||
selector: (c, mq) => mq.size.width,
|
selector: (c, mq) => mq.size.width,
|
||||||
builder: (c, mqWidth, child) {
|
builder: (c, mqWidth, child) {
|
||||||
|
@ -68,6 +75,7 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -103,6 +111,18 @@ class _InfoPageState extends State<InfoPage> {
|
||||||
curve: Curves.easeInOut,
|
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 {
|
class _InfoPageContent extends StatefulWidget {
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/collection/empty.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_dir_tile.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_section.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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@ -109,10 +112,28 @@ class InfoSearchDelegate extends SearchDelegate {
|
||||||
icon: AIcons.info,
|
icon: AIcons.info,
|
||||||
text: 'No matching keys',
|
text: 'No matching keys',
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: NotificationListener<OpenTempEntryNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
_openTempEntry(context, notification.entry);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
itemBuilder: (context, index) => tiles[index],
|
itemBuilder: (context, index) => tiles[index],
|
||||||
itemCount: tiles.length,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,8 @@ import 'package:aves/ref/xmp.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.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/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.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_namespaces.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
|
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/exif.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/google.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/photoshop.dart';
|
||||||
import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.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/metadata/xmp_ns/xmp.dart';
|
||||||
|
import 'package:aves/widgets/viewer/info/notifications.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
@ -123,13 +122,6 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final embedEntry = AvesEntry.fromMap(fields);
|
OpenTempEntryNotification(entry: AvesEntry.fromMap(fields)).dispatch(context);
|
||||||
unawaited(Navigator.push(
|
|
||||||
context,
|
|
||||||
TransparentMaterialPageRoute(
|
|
||||||
settings: RouteSettings(name: SingleEntryViewerPage.routeName),
|
|
||||||
pageBuilder: (c, a, sa) => SingleEntryViewerPage(entry: embedEntry),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class BackUpNotification extends Notification {}
|
class BackUpNotification extends Notification {}
|
||||||
|
@ -8,3 +10,14 @@ class FilterNotification extends Notification {
|
||||||
|
|
||||||
const FilterNotification(this.filter);
|
const FilterNotification(this.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class OpenTempEntryNotification extends Notification {
|
||||||
|
final AvesEntry entry;
|
||||||
|
|
||||||
|
const OpenTempEntryNotification({
|
||||||
|
@required this.entry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{entry=$entry}';
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue