use photo_view for the paging, zoom, pan & metadata-extractor for exif

This commit is contained in:
Thibault Deckers 2019-07-22 00:18:39 +09:00
parent 4ee06358b9
commit 55ad742847
9 changed files with 277 additions and 165 deletions

View file

@ -54,6 +54,7 @@ flutter {
} }
dependencies { dependencies {
implementation 'com.drewnoakes:metadata-extractor:2.12.0'
implementation 'com.github.bumptech.glide:glide:4.9.0' implementation 'com.github.bumptech.glide:glide:4.9.0'
annotationProcessor 'androidx.annotation:annotation:1.1.0' annotationProcessor 'androidx.annotation:annotation:1.1.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'

View file

@ -16,6 +16,9 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key; import com.bumptech.glide.load.Key;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.signature.ObjectKey; 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.Dexter;
import com.karumi.dexter.PermissionToken; import com.karumi.dexter.PermissionToken;
import com.karumi.dexter.listener.PermissionDeniedResponse; import com.karumi.dexter.listener.PermissionDeniedResponse;
@ -24,6 +27,8 @@ import com.karumi.dexter.listener.PermissionRequest;
import com.karumi.dexter.listener.single.PermissionListener; import com.karumi.dexter.listener.single.PermissionListener;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -81,6 +86,10 @@ public class MainActivity extends FlutterActivity {
case "getImageEntries": case "getImageEntries":
getPermissionResult(result, this); getPermissionResult(result, this);
break; break;
case "getOverlayMetadata":
String path = call.argument("path");
getOverlayMetadata(result, path);
break;
case "getImageBytes": { case "getImageBytes": {
Map map = call.argument("entry"); Map map = call.argument("entry");
Integer width = call.argument("width"); Integer width = call.argument("width");
@ -153,11 +162,36 @@ public class MainActivity extends FlutterActivity {
}).check(); }).check();
} }
public List<Map> fetchAll(Activity activity) { List<Map> fetchAll(Activity activity) {
return new MediaStoreImageProvider().fetchAll(activity).stream() return new MediaStoreImageProvider().fetchAll(activity).stream()
.map(ImageEntry::toMap) .map(ImageEntry::toMap)
.collect(Collectors.toList()); .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<String, String> 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<BitmapWorkerTask.MyTaskParams, Void, BitmapWorkerTask.MyTaskResult> { class BitmapWorkerTask extends AsyncTask<BitmapWorkerTask.MyTaskParams, Void, BitmapWorkerTask.MyTaskResult> {

View file

@ -1,34 +1,35 @@
import 'dart:io'; import 'dart:io';
import 'dart:math'; 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: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 { class ImageFullscreenPage extends StatefulWidget {
final Map entry; final List<Map> entries;
final Uint8List thumbnail; final String initialUri;
ImageFullscreenPage({this.entry, this.thumbnail}); ImageFullscreenPage({this.entries, this.initialUri});
@override @override
ImageFullscreenPageState createState() => ImageFullscreenPageState(); ImageFullscreenPageState createState() => ImageFullscreenPageState();
} }
class ImageFullscreenPageState extends State<ImageFullscreenPage> { class ImageFullscreenPageState extends State<ImageFullscreenPage> {
int get imageWidth => widget.entry['width']; int _currentPage;
PageController _pageController;
int get imageHeight => widget.entry['height']; List<Map> get entries => widget.entries;
String get uri => widget.entry['uri'];
String get path => widget.entry['path'];
double requestWidth, requestHeight;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
var index = entries.indexWhere((entry) => entry['uri'] == widget.initialUri);
_currentPage = max(0, index);
_pageController = PageController(initialPage: _currentPage);
} }
@override @override
@ -38,50 +39,126 @@ class ImageFullscreenPageState extends State<ImageFullscreenPage> {
@override @override
Widget build(BuildContext context) { 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( return MediaQuery.removeViewInsets(
context: context, context: context,
// remove bottom view insets to paint underneath the translucent navigation bar // remove bottom view insets to paint underneath the translucent navigation bar
removeBottom: true, removeBottom: true,
child: Scaffold( child: Scaffold(
body: Hero( backgroundColor: Colors.black,
tag: uri, body: Stack(
child: Stack( alignment: Alignment.bottomCenter,
children: [ children: [
Center( PhotoViewGallery.builder(
child: widget.thumbnail == null itemCount: entries.length,
? CircularProgressIndicator() builder: (context, index) {
: Image.memory( var entry = entries[index];
widget.thumbnail, return PhotoViewGalleryPageOptions(
width: requestWidth, imageProvider: FileImage(File(entry['path'])),
height: requestHeight, heroTag: entry['uri'],
fit: BoxFit.contain, minScale: PhotoViewComputedScale.contained,
), initialScale: PhotoViewComputedScale.contained,
);
},
loadingChild: Center(
child: CircularProgressIndicator(),
), ),
Center( pageController: _pageController,
child: FadeInImage( onPageChanged: (index) {
placeholder: MemoryImage(kTransparentImage), debugPrint('onPageChanged: index=$index');
image: FileImage(File(path)), setState(() => _currentPage = index);
fadeOutDuration: Duration(milliseconds: 1), },
fadeInDuration: Duration(milliseconds: 200), transitionOnUserGestures: true,
width: requestWidth, scrollPhysics: BouncingScrollPhysics(),
height: requestHeight, ),
fit: BoxFit.contain, 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<Map> 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'])),
],
);
},
)
],
), ),
), ),
); );

View file

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

View file

@ -11,7 +11,7 @@ class ImageFetcher {
final result = await platform.invokeMethod('getImageEntries'); final result = await platform.invokeMethod('getImageEntries');
return (result as List).cast<Map>(); return (result as List).cast<Map>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('failed with exception=${e.message}'); debugPrint('getImageEntries failed with exception=${e.message}');
} }
return []; return [];
} }
@ -25,7 +25,7 @@ class ImageFetcher {
}); });
return result as Uint8List; return result as Uint8List;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('failed with exception=${e.message}'); debugPrint('getImageBytes failed with exception=${e.message}');
} }
return Uint8List(0); return Uint8List(0);
} }
@ -36,7 +36,20 @@ class ImageFetcher {
'uri': uri, 'uri': uri,
}); });
} on PlatformException catch (e) { } 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<Map> getOverlayMetadata (String path) async {
try {
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
'path': path,
});
return result as Map;
} on PlatformException catch (e) {
debugPrint('getOverlayMetadata failed with exception=${e.message}');
}
return Map();
}
}

View file

@ -1,7 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/image_fullscreen_page.dart';
import 'package:aves/model/image_fetcher.dart'; import 'package:aves/model/image_fetcher.dart';
import 'package:aves/model/mime_types.dart'; import 'package:aves/model/mime_types.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,10 +20,6 @@ class ThumbnailState extends State<Thumbnail> {
Future<Uint8List> loader; Future<Uint8List> loader;
Uint8List bytes; Uint8List bytes;
int get imageWidth => widget.entry['width'];
int get imageHeight => widget.entry['height'];
String get mimeType => widget.entry['mimeType']; String get mimeType => widget.entry['mimeType'];
String get uri => widget.entry['uri']; String get uri => widget.entry['uri'];
@ -50,62 +45,54 @@ class ThumbnailState extends State<Thumbnail> {
var isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO); var isVideo = mimeType.startsWith(MimeTypes.MIME_VIDEO);
var isGif = mimeType == MimeTypes.MIME_GIF; var isGif = mimeType == MimeTypes.MIME_GIF;
var iconSize = widget.extent / 4; var iconSize = widget.extent / 4;
return GestureDetector( return Container(
onTap: () => Navigator.push( decoration: BoxDecoration(
context, border: Border.all(
MaterialPageRoute( color: Colors.grey.shade700,
builder: (context) => ImageFullscreenPage(entry: widget.entry, thumbnail: bytes), width: 0.5,
), ),
), ),
child: Container( child: FutureBuilder(
decoration: BoxDecoration( future: loader,
border: Border.all( builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
color: Colors.grey.shade700, if (bytes == null && snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
width: 0.5, bytes = snapshot.data;
), }
), return Stack(
child: FutureBuilder( alignment: AlignmentDirectional.bottomStart,
future: loader, children: [
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) { Hero(
if (bytes == null && snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { tag: uri,
bytes = snapshot.data; child: LayoutBuilder(builder: (context, constraints) {
} // during hero animation back from a fullscreen image,
return Stack( // the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
alignment: AlignmentDirectional.bottomStart, // so we wrap the image to apply better constraints
children: [ var dim = min(constraints.maxWidth, constraints.maxHeight);
Hero( return Container(
tag: uri, alignment: Alignment.center,
child: LayoutBuilder(builder: (context, constraints) { constraints: BoxConstraints.tight(Size(dim, dim)),
// during hero animation back from a fullscreen image, child: Image.memory(
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints) bytes ?? kTransparentImage,
// so we wrap the image to apply better constraints width: dim,
var dim = min(constraints.maxWidth, constraints.maxHeight); height: dim,
return Container( fit: BoxFit.cover,
alignment: Alignment.center, ),
constraints: BoxConstraints.tight(Size(dim, dim)), );
child: Image.memory( }),
bytes ?? kTransparentImage, ),
width: dim, if (isVideo)
height: dim, Icon(
fit: BoxFit.cover, Icons.play_circle_outline,
), size: iconSize,
);
}),
), ),
if (isVideo) if (isGif)
Icon( Icon(
Icons.play_circle_outline, Icons.gif,
size: iconSize, size: iconSize,
), ),
if (isGif) ],
Icon( );
Icons.gif, }),
size: iconSize,
),
],
);
}),
),
); );
} }
} }

View file

@ -1,5 +1,7 @@
import 'package:aves/common/draggable_scrollbar.dart'; import 'package:aves/common/draggable_scrollbar.dart';
import 'package:aves/common/outlined_text.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/thumbnail.dart';
import 'package:aves/utils/date_utils.dart'; import 'package:aves/utils/date_utils.dart';
import "package:collection/collection.dart"; import "package:collection/collection.dart";
@ -8,25 +10,11 @@ import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class ThumbnailCollection extends StatelessWidget { class ThumbnailCollection extends StatelessWidget {
final List<Map> entries;
final Map<DateTime, List<Map>> sections; final Map<DateTime, List<Map>> sections;
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
ThumbnailCollection(List<Map> entries) : sections = groupBy(entries, getDayTaken); ThumbnailCollection(this.entries) : sections = groupBy(entries, ImageEntry.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);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -51,11 +39,23 @@ class ThumbnailCollection extends StatelessWidget {
sliver: SliverGrid( sliver: SliverGrid(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
var entries = sections[sectionKey]; var sectionEntries = sections[sectionKey];
if (index >= entries.length) return null; if (index >= sectionEntries.length) return null;
return Thumbnail( var entry = sectionEntries[index];
entry: entries[index], return GestureDetector(
extent: extent, onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ImageFullscreenPage(
entries: entries,
initialUri: entry['uri'],
),
),
),
child: Thumbnail(
entry: entry,
extent: extent,
),
); );
}, },
childCount: sections[sectionKey].length, childCount: sections[sectionKey].length,

View file

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
after_layout:
dependency: transitive
description:
name: after_layout
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.7+2"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -81,6 +88,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.7.0" 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: quiver:
dependency: transitive dependency: transitive
description: description:

View file

@ -22,6 +22,7 @@ dependencies:
collection: collection:
flutter_sticky_header: flutter_sticky_header:
intl: intl:
photo_view:
transparent_image: transparent_image:
dev_dependencies: dev_dependencies:
@ -31,34 +32,3 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true 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