export: support embedded images

This commit is contained in:
Thibault Deckers 2021-01-25 12:43:04 +09:00
parent c4fdd38850
commit 218db5d091
18 changed files with 468 additions and 438 deletions

View file

@ -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<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
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,
)
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)
}
}

View file

@ -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<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 {
private val LOG_TAG = LogUtils.createTag(MediaStoreImageProvider::class.java)

View file

@ -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<AvesApp> {
Widget build(BuildContext context) {
// place the settings provider above `MaterialApp`
// so it can be used during navigation transitions
return SettingsProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
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<Settings>.value(
value: settings,
child: Provider<CollectionSource>(
create: (context) => MediaStoreSource(),
child: OverlaySupport(
child: FutureBuilder<void>(
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,
);
},
),
),
),
);

View file

@ -164,6 +164,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
}
Future<void> init();
Future<void> refresh();
Future<void> refreshMetadata(Set<AvesEntry> entries);

View file

@ -13,6 +13,7 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
import 'package:pedantic/pedantic.dart';
class MediaStoreSource extends CollectionSource {
@override
Future<void> init() async {
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;

View file

@ -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,
),

View file

@ -82,7 +82,7 @@ mixin FeedbackMixin {
Future<void> _hideOpReportOverlay() async {
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
_opReportOverlayEntry.remove();
_opReportOverlayEntry?.remove();
_opReportOverlayEntry = null;
}
}

View file

@ -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,
);
}
}

View file

@ -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<HomePage> {
MediaStoreSource _mediaStore;
AvesEntry _viewerEntry;
String _shortcutRouteName;
List<String> _shortcutFilters;
@ -100,9 +100,9 @@ class _HomePageState extends State<HomePage> {
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<CollectionSource>();
await source.init();
unawaited(source.refresh());
}
unawaited(Navigator.pushReplacement(context, _getRedirectRoute()));
@ -121,8 +121,10 @@ class _HomePageState extends State<HomePage> {
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<HomePage> {
routeName = _shortcutRouteName ?? settings.homePage.routeName;
filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson);
}
final source = context.read<CollectionSource>();
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<HomePage> {
settings: RouteSettings(name: CollectionPage.routeName),
builder: (_) => CollectionPage(
CollectionLens(
source: _mediaStore,
source: source,
filters: filters,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,

View file

@ -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<void> _showExportDialog(BuildContext context, AvesEntry entry) async {
String destinationAlbum;
if (hasCollection) {
final source = collection.source;
destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
settings: RouteSettings(name: AlbumPickPage.routeName),
builder: (context) => AlbumPickPage(source: source, moveType: MoveType.export),
),
);
} else {
destinationAlbum = entry.directory;
}
final source = context.read<CollectionSource>();
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
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();
},
);
}

View 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(() {});
}
}

View file

@ -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,
),

View file

@ -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<EntryViewerStack> with SingleTickerPr
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(() {});
}
}

View file

@ -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<InfoPage> {
bottom: false,
child: NotificationListener(
onNotification: _handleTopScroll,
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry>(
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<OpenTempEntryNotification>(
onNotification: (notification) {
_openTempEntry(notification.entry);
return true;
},
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry>(
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<InfoPage> {
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 {

View file

@ -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<OpenTempEntryNotification>(
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,
),
),
);
}
}

View file

@ -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<XmpDirTile> 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);
}
}

View file

@ -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}';
}