diff --git a/android/app/build.gradle b/android/app/build.gradle index 72c71687c..6b1b91c02 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -54,6 +54,7 @@ flutter { } dependencies { + implementation 'com.drewnoakes:metadata-extractor:2.12.0' implementation 'com.github.bumptech.glide:glide:4.9.0' annotationProcessor 'androidx.annotation:annotation:1.1.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' 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 d04465937..965de40de 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -16,6 +16,9 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.Key; import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.signature.ObjectKey; +import com.drew.imaging.ImageMetadataReader; +import com.drew.metadata.Metadata; +import com.drew.metadata.exif.ExifSubIFDDirectory; import com.karumi.dexter.Dexter; import com.karumi.dexter.PermissionToken; import com.karumi.dexter.listener.PermissionDeniedResponse; @@ -24,6 +27,8 @@ import com.karumi.dexter.listener.PermissionRequest; import com.karumi.dexter.listener.single.PermissionListener; import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.InputStream; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -81,6 +86,10 @@ public class MainActivity extends FlutterActivity { case "getImageEntries": getPermissionResult(result, this); break; + case "getOverlayMetadata": + String path = call.argument("path"); + getOverlayMetadata(result, path); + break; case "getImageBytes": { Map map = call.argument("entry"); Integer width = call.argument("width"); @@ -153,11 +162,36 @@ public class MainActivity extends FlutterActivity { }).check(); } - public List fetchAll(Activity activity) { + List fetchAll(Activity activity) { return new MediaStoreImageProvider().fetchAll(activity).stream() .map(ImageEntry::toMap) .collect(Collectors.toList()); } + + void getOverlayMetadata (Result result, String path) { + try (InputStream is = new FileInputStream(path)) { + Metadata metadata = ImageMetadataReader.readMetadata(is); + ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class); + Map metadataMap = new HashMap<>(); + if (directory != null) { + if (directory.containsTag(ExifSubIFDDirectory.TAG_FNUMBER)) { + metadataMap.put("aperture", directory.getDescription(ExifSubIFDDirectory.TAG_FNUMBER)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) { + metadataMap.put("exposureTime", directory.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)) { + metadataMap.put("focalLength", directory.getDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH)); + } + if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) { + metadataMap.put("iso", "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)); + } + } + result.success(metadataMap); + } catch (Exception e) { + result.error("getOverlayMetadata-exception", "failed to get metadata for path=" + path, e); + } + } } class BitmapWorkerTask extends AsyncTask { diff --git a/lib/image_fullscreen_page.dart b/lib/image_fullscreen_page.dart index 4c5f3da4d..107acf67c 100644 --- a/lib/image_fullscreen_page.dart +++ b/lib/image_fullscreen_page.dart @@ -1,34 +1,35 @@ import 'dart:io'; import 'dart:math'; -import 'dart:typed_data'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/image_fetcher.dart'; import 'package:flutter/material.dart'; -import 'package:transparent_image/transparent_image.dart'; +import 'package:intl/intl.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; class ImageFullscreenPage extends StatefulWidget { - final Map entry; - final Uint8List thumbnail; + final List entries; + final String initialUri; - ImageFullscreenPage({this.entry, this.thumbnail}); + ImageFullscreenPage({this.entries, this.initialUri}); @override ImageFullscreenPageState createState() => ImageFullscreenPageState(); } class ImageFullscreenPageState extends State { - int get imageWidth => widget.entry['width']; + int _currentPage; + PageController _pageController; - int get imageHeight => widget.entry['height']; - - String get uri => widget.entry['uri']; - - String get path => widget.entry['path']; - - double requestWidth, requestHeight; + List get entries => widget.entries; @override void initState() { super.initState(); + var index = entries.indexWhere((entry) => entry['uri'] == widget.initialUri); + _currentPage = max(0, index); + _pageController = PageController(initialPage: _currentPage); } @override @@ -38,50 +39,126 @@ class ImageFullscreenPageState extends State { @override Widget build(BuildContext context) { - if (requestWidth == null || requestHeight == null) { - var mediaQuery = MediaQuery.of(context); - var screenSize = mediaQuery.size; - var dpr = mediaQuery.devicePixelRatio; - requestWidth = imageWidth * dpr; - requestHeight = imageHeight * dpr; - if (imageWidth > screenSize.width || imageHeight > screenSize.height) { - var ratio = max(imageWidth / screenSize.width, imageHeight / screenSize.height); - requestWidth /= ratio; - requestHeight /= ratio; - } - } return MediaQuery.removeViewInsets( context: context, // remove bottom view insets to paint underneath the translucent navigation bar removeBottom: true, child: Scaffold( - body: Hero( - tag: uri, - child: Stack( - children: [ - Center( - child: widget.thumbnail == null - ? CircularProgressIndicator() - : Image.memory( - widget.thumbnail, - width: requestWidth, - height: requestHeight, - fit: BoxFit.contain, - ), + backgroundColor: Colors.black, + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + PhotoViewGallery.builder( + itemCount: entries.length, + builder: (context, index) { + var entry = entries[index]; + return PhotoViewGalleryPageOptions( + imageProvider: FileImage(File(entry['path'])), + heroTag: entry['uri'], + minScale: PhotoViewComputedScale.contained, + initialScale: PhotoViewComputedScale.contained, + ); + }, + loadingChild: Center( + child: CircularProgressIndicator(), ), - Center( - child: FadeInImage( - placeholder: MemoryImage(kTransparentImage), - image: FileImage(File(path)), - fadeOutDuration: Duration(milliseconds: 1), - fadeInDuration: Duration(milliseconds: 200), - width: requestWidth, - height: requestHeight, - fit: BoxFit.contain, - ), + pageController: _pageController, + onPageChanged: (index) { + debugPrint('onPageChanged: index=$index'); + setState(() => _currentPage = index); + }, + transitionOnUserGestures: true, + scrollPhysics: BouncingScrollPhysics(), + ), + if (_currentPage != null) + FullscreenOverlay( + entry: entries[_currentPage], + index: _currentPage, + total: entries.length, ), - ], - ), + ], + ), +// Hero( +// tag: uri, +// child: Stack( +// children: [ +// Center( +// child: widget.thumbnail == null +// ? CircularProgressIndicator() +// : Image.memory( +// widget.thumbnail, +// width: requestWidth, +// height: requestHeight, +// fit: BoxFit.contain, +// ), +// ), +// Center( +// child: FadeInImage( +// placeholder: MemoryImage(kTransparentImage), +// image: FileImage(File(path)), +// fadeOutDuration: Duration(milliseconds: 1), +// fadeInDuration: Duration(milliseconds: 200), +// width: requestWidth, +// height: requestHeight, +// fit: BoxFit.contain, +// ), +// ), +// ], +// ), +// ), + ), + ); + } +} + +class FullscreenOverlay extends StatelessWidget { + final Map entry; + final int index, total; + + FullscreenOverlay({this.entry, this.index, this.total}); + + @override + Widget build(BuildContext context) { + debugPrint('FullscreenOverlay MediaQuery.of(context)=${MediaQuery.of(context)}'); + // TODO TLAD find actual value from MediaQuery before insets removal + var viewInsetsBottom = 46.0; + var date = ImageEntry.getBestDate(entry); + return IgnorePointer( + child: Container( + padding: EdgeInsets.all(8.0).add(EdgeInsets.only(bottom: viewInsetsBottom)), + color: Colors.black45, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$index / $total - ${entry['title']}'), + Row( + children: [ + Expanded(child: Text('${DateFormat.yMMMMd().format(date)} – ${DateFormat.Hm().format(date)}')), + Expanded(child: Text('${entry['width']} × ${entry['height']}')), + ], + ), + FutureBuilder( + future: ImageFetcher.getOverlayMetadata(entry['path']), + builder: (futureContext, AsyncSnapshot snapshot) { + if (snapshot.connectionState != ConnectionState.done || snapshot.hasError) { + return Text(''); + } + var metadata = snapshot.data; + if (metadata.isEmpty) { + return Text(''); + } + return Row( + children: [ + Expanded(child: Text(metadata['aperture'])), + Expanded(child: Text(metadata['exposureTime'])), + Expanded(child: Text(metadata['focalLength'])), + Expanded(child: Text(metadata['iso'])), + ], + ); + }, + ) + ], ), ), ); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart new file mode 100644 index 000000000..cdba25dce --- /dev/null +++ b/lib/model/image_entry.dart @@ -0,0 +1,16 @@ +class ImageEntry { + static DateTime getBestDate(Map entry) { + var dateTakenMillis = entry['sourceDateTakenMillis'] as int; + if (dateTakenMillis != null && dateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(dateTakenMillis); + + var dateModifiedSecs = entry['dateModifiedSecs'] as int; + if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); + + return null; + } + + static DateTime getDayTaken(Map entry) { + var d = getBestDate(entry); + return d == null ? null : DateTime(d.year, d.month, d.day); + } +} diff --git a/lib/model/image_fetcher.dart b/lib/model/image_fetcher.dart index f6aac55c5..0f05e90be 100644 --- a/lib/model/image_fetcher.dart +++ b/lib/model/image_fetcher.dart @@ -11,7 +11,7 @@ class ImageFetcher { final result = await platform.invokeMethod('getImageEntries'); return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('failed with exception=${e.message}'); + debugPrint('getImageEntries failed with exception=${e.message}'); } return []; } @@ -25,7 +25,7 @@ class ImageFetcher { }); return result as Uint8List; } on PlatformException catch (e) { - debugPrint('failed with exception=${e.message}'); + debugPrint('getImageBytes failed with exception=${e.message}'); } return Uint8List(0); } @@ -36,7 +36,20 @@ class ImageFetcher { 'uri': uri, }); } on PlatformException catch (e) { - debugPrint('failed with exception=${e.message}'); + debugPrint('cancelGetImageBytes failed with exception=${e.message}'); } } -} + + // return map with: 'aperture' 'exposureTime' 'focalLength' 'iso' + static Future getOverlayMetadata (String path) async { + try { + final result = await platform.invokeMethod('getOverlayMetadata', { + 'path': path, + }); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getOverlayMetadata failed with exception=${e.message}'); + } + return Map(); + } +} \ No newline at end of file diff --git a/lib/thumbnail.dart b/lib/thumbnail.dart index 60c157c93..4e69b8201 100644 --- a/lib/thumbnail.dart +++ b/lib/thumbnail.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'dart:typed_data'; -import 'package:aves/image_fullscreen_page.dart'; import 'package:aves/model/image_fetcher.dart'; import 'package:aves/model/mime_types.dart'; import 'package:flutter/material.dart'; @@ -21,10 +20,6 @@ class ThumbnailState extends State { Future loader; Uint8List bytes; - int get imageWidth => widget.entry['width']; - - int get imageHeight => widget.entry['height']; - String get mimeType => widget.entry['mimeType']; String get uri => widget.entry['uri']; @@ -50,62 +45,54 @@ class ThumbnailState extends State { var isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO); var isGif = mimeType == MimeTypes.MIME_GIF; var iconSize = widget.extent / 4; - return GestureDetector( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ImageFullscreenPage(entry: widget.entry, thumbnail: bytes), + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade700, + width: 0.5, ), ), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.shade700, - width: 0.5, - ), - ), - child: FutureBuilder( - future: loader, - builder: (futureContext, AsyncSnapshot snapshot) { - if (bytes == null && snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { - bytes = snapshot.data; - } - return Stack( - alignment: AlignmentDirectional.bottomStart, - children: [ - Hero( - tag: uri, - child: LayoutBuilder(builder: (context, constraints) { - // during hero animation back from a fullscreen image, - // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) - // so we wrap the image to apply better constraints - var dim = min(constraints.maxWidth, constraints.maxHeight); - return Container( - alignment: Alignment.center, - constraints: BoxConstraints.tight(Size(dim, dim)), - child: Image.memory( - bytes ?? kTransparentImage, - width: dim, - height: dim, - fit: BoxFit.cover, - ), - ); - }), + child: FutureBuilder( + future: loader, + builder: (futureContext, AsyncSnapshot snapshot) { + if (bytes == null && snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { + bytes = snapshot.data; + } + return Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + Hero( + tag: uri, + child: LayoutBuilder(builder: (context, constraints) { + // during hero animation back from a fullscreen image, + // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) + // so we wrap the image to apply better constraints + var dim = min(constraints.maxWidth, constraints.maxHeight); + return Container( + alignment: Alignment.center, + constraints: BoxConstraints.tight(Size(dim, dim)), + child: Image.memory( + bytes ?? kTransparentImage, + width: dim, + height: dim, + fit: BoxFit.cover, + ), + ); + }), + ), + if (isVideo) + Icon( + Icons.play_circle_outline, + size: iconSize, ), - if (isVideo) - Icon( - Icons.play_circle_outline, - size: iconSize, - ), - if (isGif) - Icon( - Icons.gif, - size: iconSize, - ), - ], - ); - }), - ), + if (isGif) + Icon( + Icons.gif, + size: iconSize, + ), + ], + ); + }), ); } } diff --git a/lib/thumbnail_collection.dart b/lib/thumbnail_collection.dart index 2f0040b82..8a7a2d714 100644 --- a/lib/thumbnail_collection.dart +++ b/lib/thumbnail_collection.dart @@ -1,5 +1,7 @@ import 'package:aves/common/draggable_scrollbar.dart'; import 'package:aves/common/outlined_text.dart'; +import 'package:aves/image_fullscreen_page.dart'; +import 'package:aves/model/image_entry.dart'; import 'package:aves/thumbnail.dart'; import 'package:aves/utils/date_utils.dart'; import "package:collection/collection.dart"; @@ -8,25 +10,11 @@ import 'package:flutter_sticky_header/flutter_sticky_header.dart'; import 'package:intl/intl.dart'; class ThumbnailCollection extends StatelessWidget { + final List entries; final Map> sections; final ScrollController scrollController = ScrollController(); - ThumbnailCollection(List entries) : sections = groupBy(entries, getDayTaken); - - static DateTime getBestDate(Map entry) { - var dateTakenMillis = entry['sourceDateTakenMillis'] as int; - if (dateTakenMillis != null && dateTakenMillis > 0) return DateTime.fromMillisecondsSinceEpoch(dateTakenMillis); - - var dateModifiedSecs = entry['dateModifiedSecs'] as int; - if (dateModifiedSecs != null && dateModifiedSecs > 0) return DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs * 1000); - - return null; - } - - static DateTime getDayTaken(Map entry) { - var d = getBestDate(entry); - return d == null ? null : DateTime(d.year, d.month, d.day); - } + ThumbnailCollection(this.entries) : sections = groupBy(entries, ImageEntry.getDayTaken); @override Widget build(BuildContext context) { @@ -51,11 +39,23 @@ class ThumbnailCollection extends StatelessWidget { sliver: SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) { - var entries = sections[sectionKey]; - if (index >= entries.length) return null; - return Thumbnail( - entry: entries[index], - extent: extent, + var sectionEntries = sections[sectionKey]; + if (index >= sectionEntries.length) return null; + var entry = sectionEntries[index]; + return GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImageFullscreenPage( + entries: entries, + initialUri: entry['uri'], + ), + ), + ), + child: Thumbnail( + entry: entry, + extent: extent, + ), ); }, childCount: sections[sectionKey].length, diff --git a/pubspec.lock b/pubspec.lock index 9840a092d..5d1019cc3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + after_layout: + dependency: transitive + description: + name: after_layout + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.7+2" async: dependency: transitive description: @@ -81,6 +88,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" quiver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e38725eba..d9a6c7416 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: collection: flutter_sticky_header: intl: + photo_view: transparent_image: dev_dependencies: @@ -31,34 +32,3 @@ dev_dependencies: flutter: uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages