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 {
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'

View file

@ -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<Map> fetchAll(Activity activity) {
List<Map> 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<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> {

View file

@ -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<Map> entries;
final String initialUri;
ImageFullscreenPage({this.entry, this.thumbnail});
ImageFullscreenPage({this.entries, this.initialUri});
@override
ImageFullscreenPageState createState() => ImageFullscreenPageState();
}
class ImageFullscreenPageState extends State<ImageFullscreenPage> {
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<Map> 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<ImageFullscreenPage> {
@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<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');
return (result as List).cast<Map>();
} 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<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: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<Thumbnail> {
Future<Uint8List> 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<Thumbnail> {
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<Uint8List> 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<Uint8List> 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,
),
],
);
}),
);
}
}

View file

@ -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<Map> entries;
final Map<DateTime, List<Map>> sections;
final ScrollController scrollController = ScrollController();
ThumbnailCollection(List<Map> 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,

View file

@ -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:

View file

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