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 f363074e6..a0223b5e7 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 @@ -217,6 +217,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler { // convenience methods + // InputStream.readAllBytes is only available from Java 9+ private byte[] getBytes(InputStream inputStream) throws IOException { ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream(); int bufferSize = 1024; diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java index 8a6defd1e..6c6891653 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/MetadataHandler.java @@ -34,6 +34,7 @@ import java.util.regex.Pattern; import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.MetadataHelper; +import deckers.thibault.aves.utils.MimeTypes; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -69,7 +70,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } private boolean isVideo(@Nullable String mimeType) { - return mimeType != null && mimeType.startsWith(Constants.MIME_VIDEO); + return mimeType != null && mimeType.startsWith(MimeTypes.VIDEO); } private InputStream getInputStream(String path, String uri) throws FileNotFoundException { @@ -171,7 +172,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { Map metadataMap = new HashMap<>(); try (InputStream is = getInputStream(path, uri)) { - if (!Constants.MIME_MP2T.equalsIgnoreCase(mimeType)) { + if (!MimeTypes.MP2T.equals(mimeType)) { Metadata metadata = ImageMetadataReader.readMetadata(is); // EXIF Sub-IFD diff --git a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java index 92ef5f665..d58b48dc9 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/ImageEntry.java @@ -27,8 +27,8 @@ import java.util.HashMap; import java.util.Map; import java.util.TimeZone; -import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.MetadataHelper; +import deckers.thibault.aves.utils.MimeTypes; import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode; @@ -100,11 +100,15 @@ public class ImageEntry { } public boolean isImage() { - return mimeType.startsWith(Constants.MIME_IMAGE); + return mimeType.startsWith(MimeTypes.IMAGE); + } + + public boolean isSvg() { + return mimeType.equals(MimeTypes.SVG); } public boolean isVideo() { - return mimeType.startsWith(Constants.MIME_VIDEO); + return mimeType.startsWith(MimeTypes.VIDEO); } // metadata retrieval @@ -179,10 +183,12 @@ public class ImageEntry { // expects entry with: uri/path, mimeType // finds: width, height, orientation, date private void fillByMetadataExtractor(Context context) { + if (MimeTypes.SVG.equals(mimeType)) return; + try (InputStream is = getInputStream(context)) { Metadata metadata = ImageMetadataReader.readMetadata(is); - if (Constants.MIME_JPEG.equals(mimeType)) { + if (MimeTypes.JPEG.equals(mimeType)) { JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class); if (jpegDir != null) { if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) { @@ -201,7 +207,7 @@ public class ImageEntry { sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime(); } } - } else if (Constants.MIME_MP4.equals(mimeType)) { + } else if (MimeTypes.MP4.equals(mimeType)) { Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class); if (mp4VideoDir != null) { if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) { @@ -220,6 +226,8 @@ public class ImageEntry { // expects entry with: uri/path // finds: width, height private void fillByBitmapDecode(Context context) { + if (MimeTypes.SVG.equals(mimeType)) return; + try (InputStream is = getInputStream(context)) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java index 6fb5f9156..c3e73c7d3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ContentImageProvider.java @@ -15,7 +15,7 @@ class ContentImageProvider extends ImageProvider { entry.mimeType = mimeType; entry.fillPreCatalogMetadata(context); - if (entry.hasSize()) { + if (entry.hasSize() || entry.isSvg()) { callback.onSuccess(entry.toMap()); } else { callback.onFailure(); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java index 895f8c899..87a339f63 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/FileImageProvider.java @@ -38,7 +38,7 @@ class FileImageProvider extends ImageProvider { } entry.fillPreCatalogMetadata(context); - if (entry.hasSize()) { + if (entry.hasSize() || entry.isSvg()) { callback.onSuccess(entry.toMap()); } else { callback.onFailure(); 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 dd8d96595..222bc32df 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,9 +33,9 @@ import java.io.InputStream; import java.util.HashMap; import java.util.Map; -import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; import deckers.thibault.aves.utils.MetadataHelper; +import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; @@ -144,10 +144,10 @@ public abstract class ImageProvider { // so we retrieve it again from the file metadata String metadataMimeType = getMimeType(activity, uri); switch (metadataMimeType != null ? metadataMimeType : mimeType) { - case Constants.MIME_JPEG: + case MimeTypes.JPEG: rotateJpeg(activity, path, uri, clockwise, callback); break; - case Constants.MIME_PNG: + case MimeTypes.PNG: rotatePng(activity, path, uri, clockwise, callback); break; default: @@ -156,7 +156,7 @@ public abstract class ImageProvider { } private void rotateJpeg(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { - final String mimeType = Constants.MIME_JPEG; + final String mimeType = MimeTypes.JPEG; String editablePath = path; boolean onSdCard = Env.isOnSdCard(activity, path); if (onSdCard) { @@ -232,7 +232,7 @@ public abstract class ImageProvider { } private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { - final String mimeType = Constants.MIME_PNG; + final String mimeType = MimeTypes.PNG; if (path == null) { callback.onFailure(); return; 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 8f6419398..59e2bac89 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 @@ -17,8 +17,8 @@ import java.util.Map; import java.util.stream.Stream; import deckers.thibault.aves.model.ImageEntry; -import deckers.thibault.aves.utils.Constants; import deckers.thibault.aves.utils.Env; +import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.PermissionManager; import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.Utils; @@ -71,10 +71,10 @@ public class MediaStoreImageProvider extends ImageProvider { entry.put("uri", uri.toString()); callback.onSuccess(entry); }; - if (mimeType.startsWith(Constants.MIME_IMAGE)) { + if (mimeType.startsWith(MimeTypes.IMAGE)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); entryCount = fetchFrom(context, onSuccess, contentUri, IMAGE_PROJECTION); - } else if (mimeType.startsWith(Constants.MIME_VIDEO)) { + } else if (mimeType.startsWith(MimeTypes.VIDEO)) { Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id); entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION); } @@ -114,13 +114,14 @@ public class MediaStoreImageProvider extends ImageProvider { // this is fine if `contentUri` does not already contain the ID final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId); final String path = cursor.getString(pathColumn); + final String mimeType = cursor.getString(mimeTypeColumn); int width = cursor.getInt(widthColumn); int height = cursor.getInt(heightColumn); Map entryMap = new HashMap() {{ put("uri", itemUri.toString()); put("path", path); - put("mimeType", cursor.getString(mimeTypeColumn)); + put("mimeType", mimeType); put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0); put("sizeBytes", cursor.getLong(sizeColumn)); put("title", cursor.getString(titleColumn)); @@ -134,7 +135,7 @@ public class MediaStoreImageProvider extends ImageProvider { entryMap.put("width", width); entryMap.put("height", height); - if (width <= 0 || height <= 0) { + if ((width <= 0 || height <= 0) && !MimeTypes.SVG.equals(mimeType)) { // some images are incorrectly registered in the Media Store, // they are valid but miss some attributes, such as width, height, orientation ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context); @@ -143,9 +144,9 @@ public class MediaStoreImageProvider extends ImageProvider { height = entry.height != null ? entry.height : 0; } - if (width <= 0 || height <= 0) { + if ((width <= 0 || height <= 0) && !MimeTypes.SVG.equals(mimeType)) { // this is probably not a real image, like "/storage/emulated/0", so we skip it - Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path); + Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType); } else { newEntryHandler.handleEntry(entryMap); entryCount++; 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 d7374d62c..cb857ac95 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 @@ -8,17 +8,6 @@ import java.util.Map; public class Constants { public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1; - // mime types - - public static final String MIME_IMAGE = "image"; - public static final String MIME_GIF = "image/gif"; - public static final String MIME_JPEG = "image/jpeg"; - public static final String MIME_PNG = "image/png"; - - public static final String MIME_VIDEO = "video"; - public static final String MIME_MP2T = "video/mp2t"; // .m2ts - public static final String MIME_MP4 = "video/mp4"; - // video metadata keys, from android.media.MediaMetadataRetriever public static final Map MEDIA_METADATA_KEYS = new HashMap() { diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java new file mode 100644 index 000000000..cffb48a54 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/utils/MimeTypes.java @@ -0,0 +1,13 @@ +package deckers.thibault.aves.utils; + +public class MimeTypes { + public static final String IMAGE = "image"; + public static final String GIF = "image/gif"; + public static final String JPEG = "image/jpeg"; + public static final String PNG = "image/png"; + public static final String SVG = "image/svg+xml"; + + public static final String VIDEO = "video"; + public static final String MP2T = "video/mp2t"; // .m2ts + public static final String MP4 = "video/mp4"; +} diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 23bdd98cf..fb9f6cc02 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -99,6 +99,8 @@ class ImageEntry { bool get isGif => mimeType == MimeTypes.MIME_GIF; + bool get isSvg => mimeType == MimeTypes.MIME_SVG; + bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO); bool get isCatalogued => catalogMetadata != null; diff --git a/lib/model/metadata_service.dart b/lib/model/metadata_service.dart index e1321b98b..610d712a2 100644 --- a/lib/model/metadata_service.dart +++ b/lib/model/metadata_service.dart @@ -8,6 +8,8 @@ class MetadataService { // return Map> (map of directories, each directory being a map of metadata label and value description) static Future getAllMetadata(ImageEntry entry) async { + if (entry.isSvg) return null; + try { final result = await platform.invokeMethod('getAllMetadata', { 'mimeType': entry.mimeType, @@ -22,6 +24,8 @@ class MetadataService { } static Future getCatalogMetadata(ImageEntry entry) async { + if (entry.isSvg) return null; + try { // return map with: // 'dateMillis': date taken in milliseconds since Epoch (long) @@ -42,6 +46,8 @@ class MetadataService { } static Future getOverlayMetadata(ImageEntry entry) async { + if (entry.isSvg) return null; + try { // return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso' final result = await platform.invokeMethod('getOverlayMetadata', { diff --git a/lib/model/mime_types.dart b/lib/model/mime_types.dart index dfd83ce93..5ea2c514b 100644 --- a/lib/model/mime_types.dart +++ b/lib/model/mime_types.dart @@ -1,6 +1,7 @@ class MimeTypes { - static const String MIME_VIDEO = 'video'; + static const String MIME_GIF = 'image/gif'; static const String MIME_JPEG = 'image/jpeg'; static const String MIME_PNG = 'image/png'; - static const String MIME_GIF = 'image/gif'; + static const String MIME_SVG = 'image/svg+xml'; + static const String MIME_VIDEO = 'video'; } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 98ee294d0..139fa203e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,7 +1,11 @@ +import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; class Constants { // as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // so we give it a `strutStyle` with a slightly larger height static const overflowStrutStyle = StrutStyle(height: 1.3); + + static const svgBackground = Colors.white; + static const svgColorFilter = ColorFilter.mode(svgBackground, BlendMode.dstOver); } diff --git a/lib/widgets/album/thumbnail.dart b/lib/widgets/album/thumbnail.dart index 1597fa2c7..145f75971 100644 --- a/lib/widgets/album/thumbnail.dart +++ b/lib/widgets/album/thumbnail.dart @@ -1,9 +1,12 @@ import 'dart:math'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/image_preview.dart'; +import 'package:aves/widgets/fullscreen/uri_picture_provider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class Thumbnail extends StatelessWidget { final ImageEntry entry; @@ -22,44 +25,6 @@ class Thumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final image = ImagePreview( - entry: entry, - // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling - width: 50, - height: 50, - builder: (bytes) { - final image = Image.memory( - bytes, - width: extent, - height: extent, - fit: BoxFit.cover, - ); - return heroTag == null - ? image - : Hero( - tag: heroTag, - flightShuttleBuilder: ( - BuildContext flightContext, - Animation animation, - HeroFlightDirection flightDirection, - BuildContext fromHeroContext, - BuildContext toHeroContext, - ) { - // use LayoutBuilder only during hero animation - return LayoutBuilder(builder: (context, constraints) { - final dim = min(constraints.maxWidth, constraints.maxHeight); - return Image.memory( - bytes, - width: dim, - height: dim, - fit: BoxFit.cover, - ); - }); - }, - child: image, - ); - }, - ); return Container( decoration: BoxDecoration( border: Border.all( @@ -72,7 +37,7 @@ class Thumbnail extends StatelessWidget { child: Stack( alignment: AlignmentDirectional.bottomStart, children: [ - image, + entry.isSvg ? _buildVectorImage() : _buildRasterImage(), _ThumbnailOverlay( entry: entry, extent: extent, @@ -81,6 +46,59 @@ class Thumbnail extends StatelessWidget { ), ); } + + Widget _buildRasterImage() { + return ImagePreview( + entry: entry, + // TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling + width: 50, + height: 50, + builder: (bytes) { + final imageBuilder = (bytes, dim) => Image.memory( + bytes, + width: dim, + height: dim, + fit: BoxFit.cover, + ); + return heroTag == null + ? imageBuilder(bytes, extent) + : Hero( + tag: heroTag, + flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { + // use LayoutBuilder only during hero animation + return LayoutBuilder(builder: (context, constraints) { + final dim = min(constraints.maxWidth, constraints.maxHeight); + return imageBuilder(bytes, dim); + }); + }, + child: imageBuilder(bytes, extent), + ); + }, + ); + } + + Widget _buildVectorImage() { + final child = Container( + // center `SvgPicture` inside `Container` with the thumbnail dimensions + // so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons + width: extent, + height: extent, + child: SvgPicture( + UriPicture( + entry.uri, + colorFilter: Constants.svgColorFilter, + ), + width: extent, + height: extent, + ), + ); + return heroTag == null + ? child + : Hero( + tag: heroTag, + child: child, + ); + } } class _ThumbnailOverlay extends StatelessWidget { diff --git a/lib/widgets/common/image_preview.dart b/lib/widgets/common/image_preview.dart index b1180cbf0..5b88c1567 100644 --- a/lib/widgets/common/image_preview.dart +++ b/lib/widgets/common/image_preview.dart @@ -36,7 +36,7 @@ class ImagePreviewState extends State with AfterInitMixin { @override void initState() { - debugPrint('$runtimeType initState path=${entry.path}'); +// debugPrint('$runtimeType initState path=${entry.path}'); super.initState(); _entryChangeNotifier = Listenable.merge([ entry.imageChangeNotifier, @@ -55,14 +55,14 @@ class ImagePreviewState extends State with AfterInitMixin { void didUpdateWidget(ImagePreview old) { // debugPrint('$runtimeType didUpdateWidget from=${old.entry.path} to=${entry.path}'); super.didUpdateWidget(old); - if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && widget.entry.width == old.entry.width && widget.entry.height == old.entry.height && widget.entry.orientationDegrees == old.entry.orientationDegrees) return; + if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && entry.width == old.entry.width && entry.height == old.entry.height && entry.orientationDegrees == old.entry.orientationDegrees) return; _initByteLoader(); } void _initByteLoader() { final width = (widget.width * _devicePixelRatio).round(); final height = (widget.height * _devicePixelRatio).round(); - _byteLoader = ImageFileService.getImageBytes(widget.entry, width, height); + _byteLoader = ImageFileService.getImageBytes(entry, width, height); } void _onEntryChange() => setState(() => _initByteLoader()); diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index a2d43ba01..e9e337bce 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -5,7 +5,7 @@ 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/image_uri.dart'; +import 'package:aves/widgets/fullscreen/uri_image_provider.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'; diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index ccc16a690..9ab77964d 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,7 +1,10 @@ import 'package:aves/model/image_entry.dart'; -import 'package:aves/widgets/fullscreen/image_uri.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/fullscreen/uri_image_provider.dart'; +import 'package:aves/widgets/fullscreen/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/video.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; import 'package:video_player/video_player.dart'; @@ -43,26 +46,47 @@ class ImageView extends StatelessWidget { ); } + final placeholderBuilder = (context) => const Center( + child: SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ); + final heroAttributes = heroTag != null + ? PhotoViewHeroAttributes( + tag: heroTag, + transitionOnUserGestures: true, + ) + : null; + + if (entry.isSvg) { + return PhotoView.customChild( + child: SvgPicture( + UriPicture( + entry.uri, + colorFilter: Constants.svgColorFilter, + ), + placeholderBuilder: placeholderBuilder, + ), + backgroundDecoration: backgroundDecoration, + heroAttributes: heroAttributes, + 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: UriImage(entry.uri), - loadingBuilder: (context, event) => const Center( - child: SizedBox( - width: 64, - height: 64, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), + loadingBuilder: (context, event) => placeholderBuilder(context), backgroundDecoration: backgroundDecoration, - heroAttributes: heroTag != null - ? PhotoViewHeroAttributes( - tag: heroTag, - transitionOnUserGestures: true, - ) - : null, + heroAttributes: heroAttributes, scaleStateChangedCallback: onScaleChanged, minScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained, diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index a3c7a71dd..aaca455f7 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -23,7 +23,7 @@ class BasicSection extends StatelessWidget { 'Title': entry.title ?? '?', 'Date': dateText, if (entry.isVideo) ..._buildVideoRows(), - 'Resolution': resolutionText, + if (!entry.isSvg) 'Resolution': resolutionText, 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?', 'URI': entry.uri ?? '?', if (entry.path != null) 'Path': entry.path, diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 4d102fad6..12feb3f09 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -237,7 +237,7 @@ class _DateRow extends StatelessWidget { const Icon(OMIcons.calendarToday, size: _iconSize), const SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), + if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), ], ); } diff --git a/lib/widgets/fullscreen/image_uri.dart b/lib/widgets/fullscreen/uri_image_provider.dart similarity index 100% rename from lib/widgets/fullscreen/image_uri.dart rename to lib/widgets/fullscreen/uri_image_provider.dart diff --git a/lib/widgets/fullscreen/uri_picture_provider.dart b/lib/widgets/fullscreen/uri_picture_provider.dart new file mode 100644 index 000000000..624496a4e --- /dev/null +++ b/lib/widgets/fullscreen/uri_picture_provider.dart @@ -0,0 +1,55 @@ +import 'package:aves/model/image_file_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pedantic/pedantic.dart'; + +class UriPicture extends PictureProvider { + const UriPicture(this.uri, {this.colorFilter}) : assert(uri != null); + + final String uri; + + /// The [ColorFilter], if any, to use when drawing this picture. + final ColorFilter colorFilter; + + @override + Future obtainKey(PictureConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + PictureStreamCompleter load(UriPicture key, {PictureErrorListener onError}) { + return OneFramePictureStreamCompleter(_loadAsync(key, onError: onError), informationCollector: () sync* { + yield DiagnosticsProperty('Uri', uri); + }); + } + + Future _loadAsync(UriPicture key, {PictureErrorListener onError}) async { + assert(key == this); + + final data = await ImageFileService.readAsBytes(uri); + if (data == null || data.isEmpty) { + return null; + } + + final decoder = SvgPicture.svgByteDecoder; + if (onError != null) { + final future = decoder(data, colorFilter, key.toString()); + unawaited(future.catchError(onError)); + return future; + } + return decoder(data, colorFilter, key.toString()); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is UriPicture && other.uri == uri && other.colorFilter == colorFilter; + } + + @override + int get hashCode => hashValues(uri, colorFilter); + + @override + String toString() => '${objectRuntimeType(this, 'UriPicture')}("$uri", colorFilter: $colorFilter)'; +}