diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 09ab72502..40524d203 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,34 +1,46 @@ + xmlns:tools="http://schemas.android.com/tools" + package="deckers.thibault.aves" + android:installLocation="auto"> + + + + - - - + + - - + + + + + + + + - diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index 1494c95bd..0fc854452 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -3,6 +3,10 @@ package deckers.thibault.aves; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.util.Log; + +import java.util.HashMap; +import java.util.Map; import deckers.thibault.aves.channelhandlers.AppAdapterHandler; import deckers.thibault.aves.channelhandlers.ImageFileHandler; @@ -11,6 +15,7 @@ import deckers.thibault.aves.channelhandlers.MetadataHandler; import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.PermissionManager; +import deckers.thibault.aves.utils.Utils; import io.flutter.app.FlutterActivity; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; @@ -18,11 +23,19 @@ import io.flutter.plugins.GeneratedPluginRegistrant; import io.flutter.view.FlutterView; public class MainActivity extends FlutterActivity { + private static final String LOG_TAG = Utils.createLogTag(MainActivity.class); + + public static final String VIEWER_CHANNEL = "deckers.thibault/aves/viewer"; + + private Map sharedEntryMap; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); GeneratedPluginRegistrant.registerWith(this); + handleIntent(getIntent()); + MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler(); FlutterView messenger = getFlutterView(); @@ -30,6 +43,27 @@ public class MainActivity extends FlutterActivity { new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler)); new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this)); new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler); + + new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler( + (call, result) -> { + if (call.method.contentEquals("getSharedEntry")) { + result.success(sharedEntryMap); + sharedEntryMap = null; + } + }); + } + + private void handleIntent(Intent intent) { + Log.i(LOG_TAG, "handleIntent intent=" + intent); + if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction())) { + Uri uri = intent.getData(); + String mimeType = intent.getType(); + if (uri != null && mimeType != null) { + sharedEntryMap = new HashMap<>(); + sharedEntryMap.put("uri", uri.toString()); + sharedEntryMap.put("mimeType", mimeType); + } + } } @Override diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java index fdff87275..b0dd3ba4c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/ImageFileHandler.java @@ -35,6 +35,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { mediaStoreStreamHandler.fetchAll(activity); result.success(null); break; + case "getImageEntry": + getImageEntry(call, result); + break; case "getImageBytes": getImageBytes(call, result); break; @@ -74,6 +77,34 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { result.success(null); } + private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { + String uriString = call.argument("uri"); + String mimeType = call.argument("mimeType"); + if (uriString == null || mimeType == null) { + result.error("getImageEntry-args", "failed because of missing arguments", null); + return; + } + + Uri uri = Uri.parse(uriString); + ImageProvider provider = ImageProviderFactory.getProvider(uri); + if (provider == null) { + result.error("getImageEntry-provider", "failed to find provider for uri=" + uriString, null); + return; + } + + provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map entry) { + result.success(entry); + } + + @Override + public void onFailure() { + result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, null); + } + }); + } + private void delete(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Map entryMap = call.argument("entry"); if (entryMap == null) { diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java index 5a4f1ac2f..1cff8c103 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MediaStoreStreamHandler.java @@ -5,9 +5,7 @@ import android.util.Log; import java.time.Duration; import java.time.Instant; -import java.util.stream.Stream; -import deckers.thibault.aves.model.ImageEntry; import deckers.thibault.aves.model.provider.MediaStoreImageProvider; import deckers.thibault.aves.utils.Utils; import io.flutter.plugin.common.EventChannel; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index e0f151138..81f35d661 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -33,6 +33,10 @@ import deckers.thibault.aves.utils.Utils; public abstract class ImageProvider { private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class); + public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) { + callback.onFailure(); + } + public void delete(final Activity activity, final String path, final Uri uri, final ImageOpCallback callback) { callback.onFailure(); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java index d1397aafa..f21406f0a 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProviderFactory.java @@ -12,9 +12,11 @@ public class ImageProviderFactory { if (scheme != null) { switch (scheme) { case ContentResolver.SCHEME_CONTENT: // content:// - String authority = uri.getAuthority(); - if (authority != null) { - switch (authority) { + // a URI's authority is [userinfo@]host[:port] + // but we only want the host when comparing to MediaStore's "authority" + String host = uri.getHost(); + if (host != null) { + switch (host) { case MediaStore.AUTHORITY: return new MediaStoreImageProvider(); // case Constants.DOWNLOADS_AUTHORITY: diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 8c9d2a4f0..b9d21cd36 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -8,8 +8,10 @@ import android.provider.MediaStore; import android.util.Log; import java.util.HashMap; +import java.util.Map; import java.util.stream.Stream; +import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; @@ -38,15 +40,32 @@ public class MediaStoreImageProvider extends ImageProvider { }).flatMap(Stream::of).toArray(String[]::new); public void fetchAll(Activity activity, EventChannel.EventSink entrySink) { - fetch(activity, entrySink, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION); - fetch(activity, entrySink, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION); + fetchFrom(activity, entrySink::success, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, null, null); + fetchFrom(activity, entrySink::success, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION, null, null); } - private void fetch(final Activity activity, EventChannel.EventSink entrySink, final Uri contentUri, String[] projection) { + @Override + public void fetchSingle(final Activity activity, final Uri uri, final String mimeType, final ImageOpCallback callback) { + long id = ContentUris.parseId(uri); + String selection = MediaStore.MediaColumns._ID + "=?"; + String[] selectionArgs = new String[]{String.valueOf(id)}; + int entryCount = 0; + if (mimeType.startsWith(Constants.MIME_IMAGE)) { + entryCount = fetchFrom(activity, callback::onSuccess, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs); + } else if (mimeType.startsWith(Constants.MIME_VIDEO)) { + entryCount = fetchFrom(activity, callback::onSuccess, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs); + } + if (entryCount == 0) { + callback.onFailure(); + } + } + + private int fetchFrom(final Activity activity, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection, String selection, String[] selectionArgs) { String orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC"; + int entryCount = 0; try { - Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, orderBy); + Cursor cursor = activity.getContentResolver().query(contentUri, projection, selection, selectionArgs, orderBy); if (cursor != null) { // image & video int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); @@ -74,7 +93,7 @@ public class MediaStoreImageProvider extends ImageProvider { // 2) extract actual mimeType with metadata-extractor // 3) update MediaStore if (width > 0) { - entrySink.success( + newEntryHandler.handleEntry( new HashMap() {{ put("uri", itemUri.toString()); put("path", cursor.getString(pathColumn)); @@ -90,6 +109,7 @@ public class MediaStoreImageProvider extends ImageProvider { put("bucketDisplayName", cursor.getString(bucketDisplayNameColumn)); put("durationMillis", durationColumn != -1 ? cursor.getLong(durationColumn) : 0); }}); + entryCount++; // } else { // // some images are incorrectly registered in the MediaStore, // // they are valid but miss some attributes, such as width, height, orientation @@ -107,6 +127,7 @@ public class MediaStoreImageProvider extends ImageProvider { } catch (Exception e) { Log.e(LOG_TAG, "failed to get entries", e); } + return entryCount; } @Override @@ -139,4 +160,8 @@ public class MediaStoreImageProvider extends ImageProvider { callback.onFailure(); } + + private interface NewEntryHandler { + void handleEntry(Map entry); + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java index 73e26fd06..e1ddbf248 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/Constants.java @@ -14,6 +14,7 @@ public class Constants { public static final String MIME_JPEG = "image/jpeg"; public static final String MIME_PNG = "image/png"; public static final String MIME_MP2T = "video/mp2t"; // .m2ts + public static final String MIME_IMAGE = "image"; public static final String MIME_VIDEO = "video"; // video metadata keys, from android.media.MediaMetadataRetriever diff --git a/lib/main.dart b/lib/main.dart index b80a2695b..4824b1dbb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,13 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_file_service.dart'; import 'package:aves/model/settings.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/viewer_service.dart'; import 'package:aves/widgets/album/all_collection_drawer.dart'; import 'package:aves/widgets/album/all_collection_page.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_store_collection_provider.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:outline_material_icons/outline_material_icons.dart'; @@ -47,6 +51,7 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + ImageEntry _sharedEntry; Future _appSetup; @override @@ -60,6 +65,7 @@ class _HomePageState extends State { Future _setup() async { debugPrint('$runtimeType _setup'); + // TODO reduce permission check time final permissions = await PermissionHandler().requestPermissions([ PermissionGroup.storage, @@ -75,6 +81,11 @@ class _HomePageState extends State { await androidFileUtils.init(); // 170ms await settings.init(); // <20ms + + final sharedExtra = await ViewerService.getSharedEntry(); + if (sharedExtra != null) { + _sharedEntry = await ImageFileService.getImageEntry(sharedExtra['uri'], sharedExtra['mimeType']); + } } @override @@ -86,7 +97,11 @@ class _HomePageState extends State { if (snapshot.hasError) return const Icon(OMIcons.error); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); debugPrint('$runtimeType FutureBuilder builder'); - return const MediaStoreCollectionPage(); + return _sharedEntry != null + ? SingleFullscreenPage( + entry: _sharedEntry, + ) + : const MediaStoreCollectionPage(); }), ); } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index ae7f58c05..ac8fc47ff 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -111,7 +111,7 @@ class ImageEntry { return width / height; } - int get megaPixels => (width * height / 1000000).round(); + int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; DateTime get bestDate { if ((catalogMetadata?.dateMillis ?? 0) > 0) return DateTime.fromMillisecondsSinceEpoch(catalogMetadata.dateMillis); diff --git a/lib/model/image_file_service.dart b/lib/model/image_file_service.dart index e19491e90..208bda5b0 100644 --- a/lib/model/image_file_service.dart +++ b/lib/model/image_file_service.dart @@ -15,6 +15,20 @@ class ImageFileService { } } + static Future getImageEntry(String uri, String mimeType) async { + debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType'); + try { + final result = await platform.invokeMethod('getImageEntry', { + 'uri': uri, + 'mimeType': mimeType, + }) as Map; + return ImageEntry.fromMap(result); + } on PlatformException catch (e) { + debugPrint('getImageEntry failed with exception=${e.message}'); + } + return null; + } + static Future getImageBytes(ImageEntry entry, int width, int height) async { if (width > 0 && height > 0) { // debugPrint('getImageBytes width=$width path=${entry.path}'); diff --git a/lib/utils/viewer_service.dart b/lib/utils/viewer_service.dart new file mode 100644 index 000000000..ac848ec35 --- /dev/null +++ b/lib/utils/viewer_service.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class ViewerService { + static const platform = MethodChannel('deckers.thibault/aves/viewer'); + + static Future getSharedEntry() async { + try { + // return nullable map with: 'uri' 'mimeType' + return await platform.invokeMethod('getSharedEntry') as Map; + } on PlatformException catch (e) { + debugPrint('getSharedEntry failed with exception=${e.message}'); + } + return {}; + } +} diff --git a/lib/widgets/album/collection_section.dart b/lib/widgets/album/collection_section.dart index d57618c67..d235f2742 100644 --- a/lib/widgets/album/collection_section.dart +++ b/lib/widgets/album/collection_section.dart @@ -75,9 +75,9 @@ class SectionSliver extends StatelessWidget { Navigator.push( context, TransparentMaterialPageRoute( - pageBuilder: (context, _, __) => FullscreenPage( + pageBuilder: (context, _, __) => MultiFullscreenPage( collection: collection, - initialUri: entry.uri, + initialEntry: entry, ), ), ); diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart new file mode 100644 index 000000000..6cb743817 --- /dev/null +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -0,0 +1,385 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:aves/model/collection_lens.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart'; +import 'package:aves/widgets/fullscreen/image_page.dart'; +import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; +import 'package:aves/widgets/fullscreen/overlay/top.dart'; +import 'package:aves/widgets/fullscreen/overlay/video.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; +import 'package:video_player/video_player.dart'; + +class FullscreenBody extends StatefulWidget { + final CollectionLens collection; + final ImageEntry initialEntry; + + const FullscreenBody({ + Key key, + this.collection, + this.initialEntry, + }) : super(key: key); + + @override + FullscreenBodyState createState() => FullscreenBodyState(); +} + +class FullscreenBodyState extends State with SingleTickerProviderStateMixin { + int _currentHorizontalPage; + ValueNotifier _currentVerticalPage; + PageController _horizontalPager, _verticalPager; + final ValueNotifier _overlayVisible = ValueNotifier(true); + AnimationController _overlayAnimationController; + Animation _topOverlayScale, _bottomOverlayScale; + Animation _bottomOverlayOffset; + EdgeInsets _frozenViewInsets, _frozenViewPadding; + FullscreenActionDelegate _actionDelegate; + final List> _videoControllers = []; + + CollectionLens get collection => widget.collection; + + bool get hasCollection => collection != null; + + List get entries => hasCollection ? collection.sortedEntries : [widget.initialEntry]; + + List get pages => hasCollection ? ['transition', 'image', 'info'] : ['image', 'info']; + + int get transitionPage => pages.indexOf('transition'); + + int get imagePage => pages.indexOf('image'); + + int get infoPage => pages.indexOf('info'); + + @override + void initState() { + super.initState(); + _currentHorizontalPage = max(0, entries.indexOf(widget.initialEntry)); + _currentVerticalPage = ValueNotifier(imagePage); + _horizontalPager = PageController(initialPage: _currentHorizontalPage); + _verticalPager = PageController(initialPage: _currentVerticalPage.value); + _overlayAnimationController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _topOverlayScale = CurvedAnimation( + parent: _overlayAnimationController, + // a little bounce at the top + curve: Curves.easeOutBack, + ); + _bottomOverlayScale = CurvedAnimation( + parent: _overlayAnimationController, + // no bounce at the bottom, to avoid video controller displacement + curve: Curves.easeOutQuad, + ); + _bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation( + parent: _overlayAnimationController, + curve: Curves.easeOutQuad, + )); + _overlayVisible.addListener(_onOverlayVisibleChange); + _actionDelegate = FullscreenActionDelegate( + collection: collection, + showInfo: () => _goToVerticalPage(infoPage), + ); + _initVideoController(); + _initOverlay(); + } + + Future _initOverlay() async { + // wait for MaterialPageRoute.transitionDuration + // to show overlay after hero animation is complete + await Future.delayed(Duration(milliseconds: (300 * timeDilation).toInt())); + await _onOverlayVisibleChange(); + } + + @override + void dispose() { + _overlayAnimationController.dispose(); + _overlayVisible.removeListener(_onOverlayVisibleChange); + _videoControllers.forEach((kv) => kv.item2.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; + return WillPopScope( + onWillPop: () { + if (_currentVerticalPage.value == infoPage) { + // back from info to image + _goToVerticalPage(imagePage); + return Future.value(false); + } + if (!ModalRoute.of(context).canPop) { + // exit app when trying to pop a fullscreen page that is a viewer for a single entry + exit(0); + } + _onLeave(); + return Future.value(true); + }, + child: Stack( + children: [ + FullscreenVerticalPageView( + collection: collection, + entry: entry, + videoControllers: _videoControllers, + verticalPager: _verticalPager, + horizontalPager: _horizontalPager, + onVerticalPageChanged: _onVerticalPageChanged, + onHorizontalPageChanged: _onHorizontalPageChanged, + onImageTap: () => _overlayVisible.value = !_overlayVisible.value, + onImagePageRequested: () => _goToVerticalPage(imagePage), + ), + ValueListenableBuilder( + valueListenable: _currentVerticalPage, + builder: (context, page, child) { + final showOverlay = entry != null && page == imagePage; + return showOverlay + ? FullscreenTopOverlay( + entries: entries, + index: _currentHorizontalPage, + scale: _topOverlayScale, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), + ) + : const SizedBox.shrink(); + }, + ), + ValueListenableBuilder( + valueListenable: _currentVerticalPage, + builder: (context, page, child) { + final showOverlay = entry != null && page == imagePage; + final videoController = showOverlay && entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2 : null; + return showOverlay + ? Positioned( + bottom: 0, + child: Column( + children: [ + if (videoController != null) + VideoControlOverlay( + entry: entry, + controller: videoController, + scale: _bottomOverlayScale, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ), + SlideTransition( + position: _bottomOverlayOffset, + child: FullscreenBottomOverlay( + entries: entries, + index: _currentHorizontalPage, + showPosition: hasCollection, + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + ), + ), + ], + ), + ) + : const SizedBox.shrink(); + }, + ), + ], + ), + ); + } + + Future _goToVerticalPage(int page) { + return _verticalPager.animateToPage( + page, + duration: Duration(milliseconds: (300 * timeDilation).toInt()), + curve: Curves.easeInOut, + ); + } + + void _onVerticalPageChanged(int page) { + _currentVerticalPage.value = page; + if (page == transitionPage) { + _onLeave(); + Navigator.pop(context); + } + } + + void _onLeave() => _showSystemUI(); + + void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); + + void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); + + Future _onOverlayVisibleChange() async { + if (_overlayVisible.value) { + _showSystemUI(); + _overlayAnimationController.forward(); + } else { + final mediaQuery = Provider.of(context, listen: false); + setState(() { + _frozenViewInsets = mediaQuery.viewInsets; + _frozenViewPadding = mediaQuery.viewPadding; + }); + _hideSystemUI(); + await _overlayAnimationController.reverse(); + _frozenViewInsets = null; + _frozenViewPadding = null; + } + } + + void _onHorizontalPageChanged(int page) { + _currentHorizontalPage = page; + _pauseVideoControllers(); + _initVideoController(); + setState(() {}); + } + + void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); + + void _initVideoController() { + final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; + if (entry == null || !entry.isVideo) return; + + final path = entry.path; + var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null); + if (controllerEntry != null) { + _videoControllers.remove(controllerEntry); + } else { + final controller = VideoPlayerController.file(File(path))..initialize(); + controllerEntry = Tuple2(path, controller); + } + _videoControllers.insert(0, controllerEntry); + while (_videoControllers.length > 3) { + _videoControllers.removeLast().item2.dispose(); + } + } +} + +class FullscreenVerticalPageView extends StatefulWidget { + final CollectionLens collection; + final ImageEntry entry; + final List> videoControllers; + final PageController horizontalPager, verticalPager; + final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; + final VoidCallback onImageTap, onImagePageRequested; + + const FullscreenVerticalPageView({ + @required this.collection, + @required this.entry, + @required this.videoControllers, + @required this.verticalPager, + @required this.horizontalPager, + @required this.onVerticalPageChanged, + @required this.onHorizontalPageChanged, + @required this.onImageTap, + @required this.onImagePageRequested, + }); + + @override + _FullscreenVerticalPageViewState createState() => _FullscreenVerticalPageViewState(); +} + +class _FullscreenVerticalPageViewState extends State { + bool _isInitialScale = true; + ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); + ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); + + CollectionLens get collection => widget.collection; + + bool get hasCollection => collection != null; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(FullscreenVerticalPageView oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + super.dispose(); + _unregisterWidget(widget); + } + + void _registerWidget(FullscreenVerticalPageView widget) { + widget.verticalPager.addListener(_onVerticalPageControllerChange); + widget.entry.imageChangeNotifier.addListener(_onImageChange); + } + + void _unregisterWidget(FullscreenVerticalPageView widget) { + widget.verticalPager.removeListener(_onVerticalPageControllerChange); + widget.entry.imageChangeNotifier.removeListener(_onImageChange); + } + + void _onVerticalPageControllerChange() { + _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(min(1.0, widget.verticalPager.page)); + } + + void _onImageChange() async { + await FileImage(File(widget.entry.path)).evict(); + // rebuild to refresh the Image inside ImagePage + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final onScaleChanged = (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial); + final pages = [ + // fake page for opacity transition between collection and fullscreen views + if (hasCollection) + const SizedBox(), + hasCollection + ? MultiImagePage( + collection: collection, + pageController: widget.horizontalPager, + onTap: widget.onImageTap, + onPageChanged: widget.onHorizontalPageChanged, + onScaleChanged: onScaleChanged, + videoControllers: widget.videoControllers, + ) + : SingleImagePage( + entry: widget.entry, + onScaleChanged: onScaleChanged, + onTap: widget.onImageTap, + videoControllers: widget.videoControllers, + ), + NotificationListener( + onNotification: (notification) { + if (notification is BackUpNotification) widget.onImagePageRequested(); + return false; + }, + child: InfoPage( + collection: collection, + entry: widget.entry, + visibleNotifier: _infoPageVisibleNotifier, + ), + ), + ]; + return ValueListenableBuilder( + valueListenable: _backgroundColorNotifier, + builder: (context, backgroundColor, child) => Container( + color: backgroundColor, + child: child, + ), + child: PageView( + scrollDirection: Axis.vertical, + controller: widget.verticalPager, + physics: _isInitialScale ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), + onPageChanged: (page) { + widget.onVerticalPageChanged(page); + _infoPageVisibleNotifier.value = page == pages.length - 1; + }, + children: pages, + ), + ); + } +} diff --git a/lib/widgets/fullscreen/fullscreen_page.dart b/lib/widgets/fullscreen/fullscreen_page.dart index 6b322f744..df76c3627 100644 --- a/lib/widgets/fullscreen/fullscreen_page.dart +++ b/lib/widgets/fullscreen/fullscreen_page.dart @@ -1,31 +1,17 @@ -import 'dart:io'; -import 'dart:math'; - import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart'; -import 'package:aves/widgets/fullscreen/image_page.dart'; -import 'package:aves/widgets/fullscreen/info/info_page.dart'; -import 'package:aves/widgets/fullscreen/overlay/bottom.dart'; -import 'package:aves/widgets/fullscreen/overlay/top.dart'; -import 'package:aves/widgets/fullscreen/overlay/video.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_body.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; -import 'package:video_player/video_player.dart'; -class FullscreenPage extends AnimatedWidget { +class MultiFullscreenPage extends AnimatedWidget { final CollectionLens collection; - final String initialUri; + final ImageEntry initialEntry; - const FullscreenPage({ + const MultiFullscreenPage({ Key key, this.collection, - this.initialUri, + this.initialEntry, }) : super(key: key, listenable: collection); @override @@ -34,7 +20,7 @@ class FullscreenPage extends AnimatedWidget { child: Scaffold( body: FullscreenBody( collection: collection, - initialUri: initialUri, + initialEntry: initialEntry, ), backgroundColor: Colors.transparent, resizeToAvoidBottomInset: false, @@ -43,342 +29,23 @@ class FullscreenPage extends AnimatedWidget { } } -class FullscreenBody extends StatefulWidget { - final CollectionLens collection; - final String initialUri; +class SingleFullscreenPage extends StatelessWidget { + final ImageEntry entry; - const FullscreenBody({ + const SingleFullscreenPage({ Key key, - this.collection, - this.initialUri, + this.entry, }) : super(key: key); - @override - FullscreenBodyState createState() => FullscreenBodyState(); -} - -class FullscreenBodyState extends State with SingleTickerProviderStateMixin { - int _currentHorizontalPage; - final ValueNotifier _currentVerticalPage = ValueNotifier(imagePage); - PageController _horizontalPager, _verticalPager; - final ValueNotifier _overlayVisible = ValueNotifier(true); - AnimationController _overlayAnimationController; - Animation _topOverlayScale, _bottomOverlayScale; - Animation _bottomOverlayOffset; - EdgeInsets _frozenViewInsets, _frozenViewPadding; - FullscreenActionDelegate _actionDelegate; - final List> _videoControllers = []; - - CollectionLens get collection => widget.collection; - - List get entries => widget.collection.sortedEntries; - - static const transitionPage = 0; - static const imagePage = 1; - static const infoPage = 2; - - @override - void initState() { - super.initState(); - final index = entries.indexWhere((entry) => entry.uri == widget.initialUri); - _currentHorizontalPage = max(0, index); - _horizontalPager = PageController(initialPage: _currentHorizontalPage); - _verticalPager = PageController(initialPage: _currentVerticalPage.value); - _overlayAnimationController = AnimationController( - duration: const Duration(milliseconds: 400), - vsync: this, - ); - _topOverlayScale = CurvedAnimation( - parent: _overlayAnimationController, - // a little bounce at the top - curve: Curves.easeOutBack, - ); - _bottomOverlayScale = CurvedAnimation( - parent: _overlayAnimationController, - // no bounce at the bottom, to avoid video controller displacement - curve: Curves.easeOutQuad, - ); - _bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation( - parent: _overlayAnimationController, - curve: Curves.easeOutQuad, - )); - _overlayVisible.addListener(_onOverlayVisibleChange); - _actionDelegate = FullscreenActionDelegate( - collection: collection, - showInfo: () => _goToVerticalPage(infoPage), - ); - _initVideoController(); - _initOverlay(); - } - - Future _initOverlay() async { - // wait for MaterialPageRoute.transitionDuration - // to show overlay after hero animation is complete - await Future.delayed(Duration(milliseconds: (300 * timeDilation).toInt())); - await _onOverlayVisibleChange(); - } - - @override - void dispose() { - _overlayAnimationController.dispose(); - _overlayVisible.removeListener(_onOverlayVisibleChange); - _videoControllers.forEach((kv) => kv.item2.dispose()); - super.dispose(); - } - @override Widget build(BuildContext context) { - final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; - return WillPopScope( - onWillPop: () { - if (_currentVerticalPage.value == infoPage) { - _goToVerticalPage(imagePage); - return Future.value(false); - } - _onLeave(); - return Future.value(true); - }, - child: Stack( - children: [ - FullscreenVerticalPageView( - collection: collection, - entry: entry, - videoControllers: _videoControllers, - verticalPager: _verticalPager, - horizontalPager: _horizontalPager, - onVerticalPageChanged: _onVerticalPageChanged, - onHorizontalPageChanged: _onHorizontalPageChanged, - onImageTap: () => _overlayVisible.value = !_overlayVisible.value, - onImagePageRequested: () => _goToVerticalPage(imagePage), - ), - ValueListenableBuilder( - valueListenable: _currentVerticalPage, - builder: (context, page, child) { - final showOverlay = entry != null && page == imagePage; - return showOverlay - ? FullscreenTopOverlay( - entries: entries, - index: _currentHorizontalPage, - scale: _topOverlayScale, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - onActionSelected: (action) => _actionDelegate.onActionSelected(context, entry, action), - ) - : const SizedBox.shrink(); - }, - ), - ValueListenableBuilder( - valueListenable: _currentVerticalPage, - builder: (context, page, child) { - final showOverlay = entry != null && page == imagePage; - final videoController = showOverlay && entry.isVideo ? _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2 : null; - return showOverlay - ? Positioned( - bottom: 0, - child: Column( - children: [ - if (videoController != null) - VideoControlOverlay( - entry: entry, - controller: videoController, - scale: _bottomOverlayScale, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - SlideTransition( - position: _bottomOverlayOffset, - child: FullscreenBottomOverlay( - entries: entries, - index: _currentHorizontalPage, - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - ), - ), - ], - ), - ) - : const SizedBox.shrink(); - }, - ), - ], - ), - ); - } - - Future _goToVerticalPage(int page) { - return _verticalPager.animateToPage( - page, - duration: Duration(milliseconds: (300 * timeDilation).toInt()), - curve: Curves.easeInOut, - ); - } - - void _onVerticalPageChanged(int page) { - _currentVerticalPage.value = page; - if (page == transitionPage) { - _onLeave(); - Navigator.pop(context); - } - } - - void _onLeave() => _showSystemUI(); - - void _showSystemUI() => SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); - - void _hideSystemUI() => SystemChrome.setEnabledSystemUIOverlays([]); - - Future _onOverlayVisibleChange() async { - if (_overlayVisible.value) { - _showSystemUI(); - _overlayAnimationController.forward(); - } else { - final mediaQuery = Provider.of(context, listen: false); - setState(() { - _frozenViewInsets = mediaQuery.viewInsets; - _frozenViewPadding = mediaQuery.viewPadding; - }); - _hideSystemUI(); - await _overlayAnimationController.reverse(); - _frozenViewInsets = null; - _frozenViewPadding = null; - } - } - - void _onHorizontalPageChanged(int page) { - _currentHorizontalPage = page; - _pauseVideoControllers(); - _initVideoController(); - setState(() {}); - } - - void _pauseVideoControllers() => _videoControllers.forEach((e) => e.item2.pause()); - - void _initVideoController() { - final entry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null; - if (entry == null || !entry.isVideo) return; - - final path = entry.path; - var controllerEntry = _videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null); - if (controllerEntry != null) { - _videoControllers.remove(controllerEntry); - } else { - final controller = VideoPlayerController.file(File(path))..initialize(); - controllerEntry = Tuple2(path, controller); - } - _videoControllers.insert(0, controllerEntry); - while (_videoControllers.length > 3) { - _videoControllers.removeLast().item2.dispose(); - } - } -} - -class FullscreenVerticalPageView extends StatefulWidget { - final CollectionLens collection; - final ImageEntry entry; - final List> videoControllers; - final PageController horizontalPager, verticalPager; - final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; - final VoidCallback onImageTap, onImagePageRequested; - - const FullscreenVerticalPageView({ - @required this.collection, - @required this.entry, - @required this.videoControllers, - @required this.verticalPager, - @required this.horizontalPager, - @required this.onVerticalPageChanged, - @required this.onHorizontalPageChanged, - @required this.onImageTap, - @required this.onImagePageRequested, - }); - - @override - _FullscreenVerticalPageViewState createState() => _FullscreenVerticalPageViewState(); -} - -class _FullscreenVerticalPageViewState extends State { - bool _isInitialScale = true; - ValueNotifier _backgroundColorNotifier = ValueNotifier(Colors.black); - ValueNotifier _infoPageVisibleNotifier = ValueNotifier(false); - - @override - void initState() { - super.initState(); - _registerWidget(widget); - } - - @override - void didUpdateWidget(FullscreenVerticalPageView oldWidget) { - super.didUpdateWidget(oldWidget); - _unregisterWidget(oldWidget); - _registerWidget(widget); - } - - @override - void dispose() { - super.dispose(); - _unregisterWidget(widget); - } - - void _registerWidget(FullscreenVerticalPageView widget) { - widget.verticalPager.addListener(_onVerticalPageControllerChange); - widget.entry.imageChangeNotifier.addListener(_onImageChange); - } - - void _unregisterWidget(FullscreenVerticalPageView widget) { - widget.verticalPager.removeListener(_onVerticalPageControllerChange); - widget.entry.imageChangeNotifier.removeListener(_onImageChange); - } - - void _onVerticalPageControllerChange() { - _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(min(1.0, widget.verticalPager.page)); - } - - void _onImageChange() async { - await FileImage(File(widget.entry.path)).evict(); - // rebuild to refresh the Image inside ImagePage - setState(() {}); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _backgroundColorNotifier, - builder: (context, backgroundColor, child) => Container( - color: backgroundColor, - child: child, - ), - child: PageView( - scrollDirection: Axis.vertical, - controller: widget.verticalPager, - physics: _isInitialScale ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), - onPageChanged: (page) { - widget.onVerticalPageChanged(page); - _infoPageVisibleNotifier.value = page == FullscreenBodyState.infoPage; - }, - children: [ - // fake page for opacity transition between collection and fullscreen views - const SizedBox(), - ImagePage( - collection: widget.collection, - pageController: widget.horizontalPager, - onTap: widget.onImageTap, - onPageChanged: widget.onHorizontalPageChanged, - onScaleChanged: (state) => setState(() => _isInitialScale = state == PhotoViewScaleState.initial), - videoControllers: widget.videoControllers, - ), - NotificationListener( - onNotification: (notification) { - if (notification is BackUpNotification) widget.onImagePageRequested(); - return false; - }, - child: InfoPage( - collection: widget.collection, - entry: widget.entry, - visibleNotifier: _infoPageVisibleNotifier, - ), - ), - ], + return MediaQueryDataProvider( + child: Scaffold( + body: FullscreenBody( + initialEntry: entry, + ), + backgroundColor: Colors.black, + resizeToAvoidBottomInset: false, ), ); } diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index e2f94409d..1ec790069 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,35 +1,33 @@ -import 'dart:io'; - import 'package:aves/model/collection_lens.dart'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/fullscreen/video.dart'; +import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; import 'package:video_player/video_player.dart'; -class ImagePage extends StatefulWidget { +class MultiImagePage extends StatefulWidget { final CollectionLens collection; final PageController pageController; - final VoidCallback onTap; final ValueChanged onPageChanged; final ValueChanged onScaleChanged; + final VoidCallback onTap; final List> videoControllers; - const ImagePage({ + const MultiImagePage({ this.collection, this.pageController, - this.onTap, this.onPageChanged, this.onScaleChanged, + this.onTap, this.videoControllers, }); @override - State createState() => ImagePageState(); + State createState() => MultiImagePageState(); } -class ImagePageState extends State with AutomaticKeepAliveClientMixin { +class MultiImagePageState extends State with AutomaticKeepAliveClientMixin { List get entries => widget.collection.sortedEntries; @override @@ -37,9 +35,6 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin super.build(context); const scrollDirection = Axis.horizontal; - const backgroundDecoration = BoxDecoration(color: Colors.transparent); - final scaleStateChangedCallback = widget.onScaleChanged; - return PhotoViewGestureDetectorScope( axis: scrollDirection, child: PageView.builder( @@ -48,46 +43,12 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin itemCount: entries.length, itemBuilder: (context, index) { final entry = entries[index]; - if (entry.isVideo) { - final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2; - return PhotoView.customChild( - child: videoController != null - ? AvesVideo( - entry: entry, - controller: videoController, - ) - : const SizedBox(), - backgroundDecoration: backgroundDecoration, - // no hero as most videos fullscreen image is different from its thumbnail - scaleStateChangedCallback: scaleStateChangedCallback, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => widget.onTap?.call(), - ); - } - return PhotoView( - // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), - imageProvider: FileImage(File(entry.path)), - loadingBuilder: (context, event) => const Center( - child: SizedBox( - width: 64, - height: 64, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), - backgroundDecoration: backgroundDecoration, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.collection.heroTag(entry), - transitionOnUserGestures: true, - ), - scaleStateChangedCallback: scaleStateChangedCallback, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => widget.onTap?.call(), - filterQuality: FilterQuality.low, + return ImageView( + entry: entry, + heroTag: widget.collection.heroTag(entry), + onScaleChanged: widget.onScaleChanged, + onTap: widget.onTap, + videoControllers: widget.videoControllers, ); }, scrollDirection: scrollDirection, @@ -99,3 +60,37 @@ class ImagePageState extends State with AutomaticKeepAliveClientMixin @override bool get wantKeepAlive => true; } + +class SingleImagePage extends StatefulWidget { + final ImageEntry entry; + final ValueChanged onScaleChanged; + final VoidCallback onTap; + final List> videoControllers; + + const SingleImagePage({ + this.entry, + this.onScaleChanged, + this.onTap, + this.videoControllers, + }); + + @override + State createState() => SingleImagePageState(); +} + +class SingleImagePageState extends State with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + + return ImageView( + entry: widget.entry, + onScaleChanged: widget.onScaleChanged, + onTap: widget.onTap, + videoControllers: widget.videoControllers, + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart new file mode 100644 index 000000000..89ee355a4 --- /dev/null +++ b/lib/widgets/fullscreen/image_view.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/fullscreen/video.dart'; +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:transparent_image/transparent_image.dart'; +import 'package:tuple/tuple.dart'; +import 'package:video_player/video_player.dart'; + +class ImageView extends StatelessWidget { + final ImageEntry entry; + final Object heroTag; + final ValueChanged onScaleChanged; + final VoidCallback onTap; + final List> videoControllers; + + const ImageView({ + this.entry, + this.heroTag, + this.onScaleChanged, + this.onTap, + this.videoControllers, + }); + + @override + Widget build(BuildContext context) { + const backgroundDecoration = BoxDecoration(color: Colors.transparent); + + if (entry.isVideo) { + final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.path, orElse: () => null)?.item2; + return PhotoView.customChild( + child: videoController != null + ? AvesVideo( + entry: entry, + controller: videoController, + ) + : const SizedBox(), + backgroundDecoration: backgroundDecoration, + // no hero as most videos fullscreen image is different from its thumbnail + scaleStateChangedCallback: onScaleChanged, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + onTapUp: (tapContext, details, value) => onTap?.call(), + ); + } + + return PhotoView( + // key includes size and orientation to refresh when the image is rotated + key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), + imageProvider: entry.path != null ? FileImage(File(entry.path)) : MemoryImage(kTransparentImage), + loadingBuilder: (context, event) => const Center( + child: SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + backgroundDecoration: backgroundDecoration, + heroAttributes: heroTag != null + ? PhotoViewHeroAttributes( + tag: heroTag, + transitionOnUserGestures: true, + ) + : null, + scaleStateChangedCallback: onScaleChanged, + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + onTapUp: (tapContext, details, value) => onTap?.call(), + filterQuality: FilterQuality.low, + ); + } +} diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 316244286..dd58d9976 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -15,19 +15,19 @@ class BasicSection extends StatelessWidget { @override Widget build(BuildContext context) { final date = entry.bestDate; - final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}'; - final resolutionText = '${entry.width} × ${entry.height}${(entry.isVideo || entry.isGif) ? '' : ' (${entry.megaPixels} MP)'}'; + final dateText = date != null ? '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}' : '?'; + final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${(entry.isVideo || entry.isGif || entry.megaPixels == null) ? '' : ' (${entry.megaPixels} MP)'}'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - InfoRow('Title', entry.title), + InfoRow('Title', entry.title ?? '?'), InfoRow('Date', dateText), if (entry.isVideo) ..._buildVideoRows(), InfoRow('Resolution', resolutionText), - InfoRow('Size', formatFilesize(entry.sizeBytes)), - InfoRow('URI', entry.uri), - InfoRow('Path', entry.path), + InfoRow('Size', entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?'), + InfoRow('URI', entry.uri ?? '?'), + InfoRow('Path', entry.path ?? '?'), ], ); } diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index a1554368a..fd2c255e4 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -16,12 +16,14 @@ import 'package:tuple/tuple.dart'; class FullscreenBottomOverlay extends StatefulWidget { final List entries; final int index; + final bool showPosition; final EdgeInsets viewInsets, viewPadding; const FullscreenBottomOverlay({ Key key, @required this.entries, @required this.index, + @required this.showPosition, this.viewInsets, this.viewPadding, }) : super(key: key); @@ -91,7 +93,7 @@ class _FullscreenBottomOverlayState extends State { : _FullscreenBottomOverlayContent( entry: _lastEntry, details: _lastDetails, - position: '${widget.index + 1}/${widget.entries.length}', + position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, maxWidth: overlayContentMaxWidth, ); }, @@ -149,7 +151,7 @@ class _FullscreenBottomOverlayContent extends StatelessWidget { children: [ SizedBox( width: maxWidth, - child: Text('$position – ${entry.title}', strutStyle: Constants.overflowStrutStyle), + child: Text('${position != null ? '$position – ' : ''}${entry.title ?? '?'}', strutStyle: Constants.overflowStrutStyle), ), if (entry.hasGps) Container( @@ -219,8 +221,8 @@ class _DateRow extends StatelessWidget { @override Widget build(BuildContext context) { final date = entry.bestDate; - final dateText = '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}'; - final resolution = '${entry.width} × ${entry.height}'; + final dateText = date != null ? '${DateFormat.yMMMd().format(date)} at ${DateFormat.Hm().format(date)}' : '?'; + final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; return Row( children: [ const Icon(OMIcons.calendarToday, size: _iconSize), diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index aa51be6ea..8b9dc3b45 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -34,7 +34,7 @@ class FullscreenTopOverlay extends StatelessWidget { children: [ OverlayButton( scale: scale, - child: const BackButton(), + child: ModalRoute.of(context)?.canPop ?? true ? const BackButton(): const CloseButton(), ), const Spacer(), OverlayButton(