Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-08-29 10:18:31 +09:00
commit 3b4aad8d22
66 changed files with 1107 additions and 304 deletions

View file

@ -43,14 +43,15 @@ jobs:
# `KEY_JKS` should contain the result of:
# gpg -c --armor keystore.jks
# `KEY_JKS_PASSPHRASE` should contain the passphrase used for the command above
# The SkSL bundle must be produced with the same Flutter engine as the one used to build the artifact
# flutter build <subcommand> --bundle-sksl-path shaders.sksl.json
run: |
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc
flutter build apk
flutter build appbundle
flutter build apk --bundle-sksl-path shaders.sksl.json
flutter build appbundle --bundle-sksl-path shaders.sksl.json
rm $AVES_STORE_FILE
env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks

8
.gitignore vendored
View file

@ -22,6 +22,7 @@
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
@ -33,6 +34,11 @@
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Exceptions to above rules.
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
android/key.properties

View file

@ -23,18 +23,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- handle intents to view or pick images
- support Android API 24 ~ 30 (Nougat ~ R)
## Roadmap
If time permits, I intend to eventually add these:
- settings & preferences
- feature: XMP tag edition (likely with [pixymeta-android](https://github.com/dragon66/pixymeta-android))
- feature: location edition
- map view
- gesture: long press and drag thumbnails to select multiple items
- gesture: double tap and drag image to zoom in/out (aka quick scale, one finger zoom)
- support: burst groups
- subsampling/tiling
## Known Issues
- privacy: cannot opt out of Crashlytics reporting (cf [flutterfire issue #1143](https://github.com/FirebaseExtended/flutterfire/issues/1143))

5
android/.gitignore vendored
View file

@ -1,8 +1,11 @@
gradle-wrapper.jar
/.gradle
/build/
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties

View file

@ -31,7 +31,6 @@
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission once this issue is fixed:
https://github.com/flutter/flutter/issues/42349
https://github.com/flutter/flutter/issues/42451
-->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@ -46,16 +45,19 @@
<application
android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/AppTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@ -65,6 +67,7 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
@ -72,16 +75,15 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PICK" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" />
</intent-filter>
<meta-data
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
android:value="false" />
</activity>
<!-- file provider to share files having a file:// URI -->
<provider

View file

@ -5,7 +5,6 @@ import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.MediaStore;
@ -18,6 +17,7 @@ import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
@ -28,6 +28,7 @@ import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodChannel;
@ -136,12 +137,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
Integer orientationDegrees = entry.orientationDegrees;
if (orientationDegrees != null && orientationDegrees != 0) {
Matrix matrix = new Matrix();
matrix.postRotate(orientationDegrees);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
}
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
}
return bitmap;
}
@ -151,7 +147,6 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
AvesImageEntry entry = params.entry;
int width = params.width;
int height = params.height;
// Log.d(LOG_TAG, "getThumbnailByGlide width=" + width + ", path=" + entry.path);
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.orientationDegrees);
@ -178,12 +173,24 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
}
try {
return target.get();
Bitmap bitmap = target.get();
String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
}
return bitmap;
} finally {
Glide.with(activity).clear(target);
}
}
private Bitmap rotateBitmap(Bitmap bitmap, Integer orientationDegrees) {
if (bitmap != null && orientationDegrees != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
}
return bitmap;
}
@Override
protected void onPostExecute(Result result) {
MethodChannel.Result r = result.params.result;

View file

@ -20,6 +20,7 @@ import com.adobe.internal.xmp.properties.XMPProperty;
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
import com.drew.imaging.ImageMetadataReader;
import com.drew.lang.GeoLocation;
import com.drew.lang.Rational;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.Tag;
@ -34,6 +35,7 @@ import com.drew.metadata.xmp.XmpDirectory;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -147,9 +149,11 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (Directory dir : metadata.getDirectories()) {
if (dir.getTagCount() > 0) {
Map<String, String> dirMap = new HashMap<>();
// directory name
metadataMap.put(dir.getName(), dirMap);
String dirName = dir.getName();
Map<String, String> dirMap = Objects.requireNonNull(metadataMap.getOrDefault(dirName, new HashMap<>()));
metadataMap.put(dirName, dirMap);
// tags
for (Tag tag : dir.getTags()) {
dirMap.put(tag.getTagName(), tag.getDescription());
@ -246,14 +250,13 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type
FileTypeDirectory fileTypeDir = metadata.getFirstDirectoryOfType(FileTypeDirectory.class);
if (fileTypeDir != null) {
for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) {
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
// file extension is unreliable
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (fileTypeDir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
metadataMap.put(KEY_MIME_TYPE, fileTypeDir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE));
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
metadataMap.put(KEY_MIME_TYPE, dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE));
}
}
@ -264,9 +267,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
// GPS
GpsDirectory gpsDir = metadata.getFirstDirectoryOfType(GpsDirectory.class);
if (gpsDir != null) {
GeoLocation geoLocation = gpsDir.getGeoLocation();
for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) {
GeoLocation geoLocation = dir.getGeoLocation();
if (geoLocation != null) {
metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude());
metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude());
@ -274,9 +276,8 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
// XMP
XmpDirectory xmpDir = metadata.getFirstDirectoryOfType(XmpDirectory.class);
if (xmpDir != null) {
XMPMeta xmpMeta = xmpDir.getXMPMeta();
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) {
StringBuilder sb = new StringBuilder();
@ -301,10 +302,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
if (MimeTypes.GIF.equals(mimeType)) {
metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class));
} else if (MimeTypes.WEBP.equals(mimeType)) {
WebpDirectory webpDir = metadata.getFirstDirectoryOfType(WebpDirectory.class);
if (webpDir != null) {
if (webpDir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, webpDir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) {
if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
}
}
}
@ -373,11 +373,25 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
ExifSubIFDDirectory directory = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (directory != null) {
for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER);
putStringFromTag(metadataMap, KEY_EXPOSURE_TIME, directory, ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH);
if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
Rational rational = directory.getRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
long num = rational.getNumerator();
long denom = rational.getDenominator();
if (num > denom) {
metadataMap.put(KEY_EXPOSURE_TIME, rational.toSimpleString(true) + "");
} else {
if (num != 1 && num != 0) {
rational = new Rational(1, Math.round(denom / (double) num));
}
metadataMap.put(KEY_EXPOSURE_TIME, rational.toString());
}
}
if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) {
metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
}
@ -448,8 +462,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
// convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
Directory dir = metadata.getFirstDirectoryOfType(dirClass);
if (dir != null) {
for (T dir : metadata.getDirectoriesOfType(dirClass)) {
putDateFromTag(metadataMap, key, dir, tag);
}
}
@ -466,12 +479,6 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
}
private static void putStringFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getString(tag));
}
}
private static void putLocalizedTextFromXmp(Map<String, Object> metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.Build;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
@ -51,6 +52,10 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
}
break;
}
case "scanFile": {
scanFile(call, new MethodResultWrapper(result));
break;
}
default:
result.notImplemented();
break;
@ -82,4 +87,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
}
return volumes;
}
private void scanFile(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path");
String mimeType = call.argument("mimeType");
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, uri) -> {
result.success(uri != null ? uri.toString() : null);
});
}
}

View file

@ -3,17 +3,15 @@ package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.graphics.ImageDecoder;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.Target;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@ -30,6 +28,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Activity activity;
private Uri uri;
private String mimeType;
private int orientationDegrees;
private EventChannel.EventSink eventSink;
private Handler handler;
@ -40,6 +39,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
this.orientationDegrees = (int) argMap.get("orientationDegrees");
}
}
@ -74,7 +74,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, uri))
.submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL);
.submit();
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
@ -88,23 +88,34 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
}
} catch (Exception e) {
error("getImage-video-exception", "failed to get image from uri=" + uri, e.getMessage());
}
} finally {
Glide.with(activity).clear(target);
}
} else {
ContentResolver cr = activity.getContentResolver();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && (MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType))) {
// as of Flutter v1.15.17, Dart Image.memory cannot decode HEIF/HEIC images
// so we convert the image using Android native decoder
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images
// so we convert the image on platform side first
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.load(uri)
.submit();
try {
ImageDecoder.Source source = ImageDecoder.createSource(cr, uri);
Bitmap bitmap = ImageDecoder.decodeBitmap(source);
Bitmap bitmap = target.get();
if (bitmap != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
success(stream.toByteArray());
} catch (IOException e) {
error("getImage-image-decode-exception", "failed to decode image from uri=" + uri, e.getMessage());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
} finally {
Glide.with(activity).clear(target);
}
} else {
try (InputStream is = cr.openInputStream(uri)) {

View file

@ -21,9 +21,9 @@ public class AvesImageEntry {
this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path");
this.mimeType = (String) map.get("mimeType");
this.width = (int) map.get("width");
this.height = (int) map.get("height");
this.orientationDegrees = (int) map.get("orientationDegrees");
this.width = (Integer) map.get("width");
this.height = (Integer) map.get("height");
this.orientationDegrees = (Integer) map.get("orientationDegrees");
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
}

View file

@ -190,51 +190,46 @@ public class SourceImageEntry {
Metadata metadata = ImageMetadataReader.readMetadata(is);
if (MimeTypes.JPEG.equals(sourceMimeType)) {
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = jpegDir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = jpegDir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
}
ExifIFD0Directory exifDir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (exifDir != null) {
if (exifDir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(exifDir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (exifDir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} else if (MimeTypes.MP4.equals(sourceMimeType)) {
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
if (mp4VideoDir != null) {
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_WIDTH);
for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = mp4VideoDir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
}
Mp4Directory mp4Dir = metadata.getFirstDirectoryOfType(Mp4Directory.class);
if (mp4Dir != null) {
if (mp4Dir.containsTag(Mp4Directory.TAG_DURATION)) {
durationMillis = mp4Dir.getLong(Mp4Directory.TAG_DURATION);
for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) {
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION);
}
}
} else if (MimeTypes.AVI.equals(sourceMimeType)) {
AviDirectory aviDir = metadata.getFirstDirectoryOfType(AviDirectory.class);
if (aviDir != null) {
if (aviDir.containsTag(AviDirectory.TAG_WIDTH)) {
width = aviDir.getInt(AviDirectory.TAG_WIDTH);
for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH);
}
if (aviDir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = aviDir.getInt(AviDirectory.TAG_HEIGHT);
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT);
}
if (aviDir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = aviDir.getLong(AviDirectory.TAG_DURATION);
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
}
}
}

View file

@ -5,7 +5,6 @@ import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.provider.MediaStore;
@ -14,6 +13,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.exifinterface.media.ExifInterface;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
@ -196,11 +196,7 @@ public abstract class ImageProvider {
callback.onFailure(new Exception("failed to decode image at path=" + path));
return;
}
Matrix matrix = new Matrix();
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1);
Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true);
Bitmap rotatedImage = TransformationUtils.rotateImage(originalImage, clockwise ? 90 : -90);
try (FileOutputStream fos = new FileOutputStream(editablePath)) {
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
@ -213,8 +209,8 @@ public abstract class ImageProvider {
}
// update fields in media store
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight;
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth;
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalImage.getHeight();
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalImage.getWidth();
Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight);

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.utils;
public class MimeTypes {
public static final String IMAGE = "image";
public static final String DNG = "image/x-adobe-dng";
public static final String GIF = "image/gif";
public static final String HEIC = "image/heic";
public static final String HEIF = "image/heif";

View file

@ -1,5 +1,4 @@
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true
android.enableR8=true

View file

@ -1,15 +1,11 @@
include ':app'
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
def plugins = new Properties()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
plugins.each { name, path ->
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
include ":$name"
project(":$name").projectDir = pluginDirectory
}
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View file

@ -38,6 +38,9 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
int get displayPriority => collectionFilterOrder.indexOf(typeKey);
// to be used as widget key
String get key => '$typeKey-$label';
@override
int compareTo(CollectionFilter other) {
final c = displayPriority.compareTo(other.displayPriority);

View file

@ -1,6 +1,5 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/widgets.dart';
@ -34,14 +33,15 @@ class MimeFilter extends CollectionFilter {
_label ??= lowMime.split('/')[0].toUpperCase();
} else {
_filter = (entry) => entry.mimeType == lowMime;
if (lowMime == MimeTypes.svg) {
_label = 'SVG';
}
_label ??= lowMime.split('/')[1].toUpperCase();
_label = displayType(lowMime);
}
_icon ??= AIcons.vector;
}
static String displayType(String mime) {
return mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
}
@override
bool filter(ImageEntry entry) => _filter(entry);

View file

@ -41,15 +41,16 @@ class ImageEntry {
String path,
this.contentId,
this.sourceMimeType,
this.width,
this.height,
@required this.width,
@required this.height,
this.orientationDegrees,
this.sizeBytes,
this.sourceTitle,
this.dateModifiedSecs,
this.sourceDateTakenMillis,
this.durationMillis,
}) {
}) : assert(width != null),
assert(height != null) {
this.path = path;
}
@ -86,8 +87,8 @@ class ImageEntry {
path: map['path'] as String,
contentId: map['contentId'] as int,
sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int,
height: map['height'] as int,
width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0,
orientationDegrees: map['orientationDegrees'] as int,
sizeBytes: map['sizeBytes'] as int,
sourceTitle: map['title'] as String,
@ -155,7 +156,9 @@ class ImageEntry {
bool get isSvg => mimeType == MimeTypes.svg;
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType);
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
bool get isRaw => [MimeTypes.dng].contains(mimeType);
bool get isVideo => mimeType.startsWith('video');
@ -208,7 +211,7 @@ class ImageEntry {
String _durationText;
String get durationText {
_durationText ??= formatDuration(Duration(milliseconds: durationMillis));
_durationText ??= formatDuration(Duration(milliseconds: durationMillis ?? 0));
return _durationText;
}
@ -266,7 +269,7 @@ class ImageEntry {
await catalog(background: background);
final latitude = _catalogMetadata?.latitude;
final longitude = _catalogMetadata?.longitude;
if (latitude == null || longitude == null) return;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return;
final coordinates = Coordinates(latitude, longitude);
try {

View file

@ -1,5 +1,6 @@
class MimeTypes {
static const String anyImage = 'image/*';
static const String dng = 'image/x-adobe-dng';
static const String gif = 'image/gif';
static const String heic = 'image/heic';
static const String heif = 'image/heif';

View file

@ -1,8 +1,11 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tuple/tuple.dart';
import 'source/enums.dart';
final Settings settings = Settings._private();
@ -24,6 +27,7 @@ class Settings {
static const infoMapStyleKey = 'info_map_style';
static const infoMapZoomKey = 'info_map_zoom';
static const launchPageKey = 'launch_page';
static const coordinateFormatKey = 'coordinates_format';
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
@ -85,6 +89,10 @@ class Settings {
set launchPage(LaunchPage newValue) => setAndNotify(launchPageKey, newValue.toString());
CoordinateFormat get coordinateFormat => getEnumOrDefault(coordinateFormatKey, CoordinateFormat.dms, CoordinateFormat.values);
set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString());
// convenience methods
// ignore: avoid_positional_boolean_parameters
@ -144,3 +152,29 @@ extension ExtraLaunchPage on LaunchPage {
}
}
}
enum CoordinateFormat { dms, decimal }
extension ExtraCoordinateFormat on CoordinateFormat {
String get name {
switch (this) {
case CoordinateFormat.dms:
return 'DMS';
case CoordinateFormat.decimal:
return 'Decimal degrees';
default:
return toString();
}
}
String format(Tuple2<double, double> latLng) {
switch (this) {
case CoordinateFormat.dms:
return toDMS(latLng).join(', ');
case CoordinateFormat.decimal:
return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', ');
default:
return toString();
}
}
}

View file

@ -12,6 +12,8 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSelectionMixin {
final CollectionSource source;
final Set<CollectionFilter> filters;
@ -214,12 +216,6 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
}
}
enum SortFactor { date, size, name }
enum GroupFactor { none, album, month, day }
enum Activity { browse, select }
mixin CollectionActivityMixin {
final ValueNotifier<Activity> _activityNotifier = ValueNotifier(Activity.browse);

View file

@ -11,6 +11,8 @@ import 'package:aves/model/source/tag.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'enums.dart';
mixin SourceBase {
final List<ImageEntry> _rawEntries = [];

View file

@ -0,0 +1,5 @@
enum SortFactor { date, size, name }
enum GroupFactor { none, album, month, day }
enum Activity { browse, select }

View file

@ -52,4 +52,19 @@ class AndroidFileService {
}
return false;
}
// return media URI
static Future<Uri> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');
try {
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
'path': path,
'mimeType': mimeType,
});
return Uri.tryParse(uriString ?? '');
} on PlatformException catch (e) {
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
}

View file

@ -66,7 +66,7 @@ class ImageFileService {
return null;
}
static Future<Uint8List> getImage(String uri, String mimeType, {int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
static Future<Uint8List> getImage(String uri, String mimeType, {int orientationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
try {
final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer();
@ -74,6 +74,7 @@ class ImageFileService {
byteChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'orientationDegrees': orientationDegrees ?? 0,
}).listen(
(data) {
final chunk = data as Uint8List;
@ -103,6 +104,9 @@ class ImageFileService {
}
static Future<Uint8List> getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) {
if (entry.isSvg) {
return Future.sync(() => Uint8List(0));
}
return servicePolicy.call(
() async {
try {

View file

@ -3,8 +3,7 @@ import 'dart:math' as math;
import 'package:intl/intl.dart';
import 'package:tuple/tuple.dart';
// adapted from Mike Mitterer's dart-latlong library
String _decimal2sexagesimal(final double dec) {
String _decimal2sexagesimal(final double degDecimal) {
double _round(final double value, {final int decimals = 6}) => (value * math.pow(10, decimals)).round() / math.pow(10, decimals);
List<int> _split(final double value) {
@ -17,19 +16,12 @@ String _decimal2sexagesimal(final double dec) {
];
}
final parts = _split(dec);
final integerPart = parts[0];
final fractionalPart = parts[1];
final deg = _split(degDecimal)[0];
final minDecimal = (degDecimal.abs() - deg) * 60;
final min = _split(minDecimal)[0];
final sec = (minDecimal - min) * 60;
final deg = integerPart;
final min = double.parse('0.$fractionalPart') * 60;
final minParts = _split(min);
final minFractionalPart = minParts[1];
final sec = double.parse('0.$minFractionalPart') * 60;
return '$deg° ${min.floor()} ${_round(sec, decimals: 2).toStringAsFixed(2)}';
return '$deg° $min ${_round(sec, decimals: 2).toStringAsFixed(2)}';
}
// return coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/filter_bar.dart';
import 'package:aves/widgets/album/search/search_delegate.dart';
@ -122,6 +123,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
}
return IconButton(
key: Key('appbar-leading-button'),
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: _browseToSelectAnimation,
@ -133,7 +135,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarTitle() {
if (collection.isBrowsing) {
Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves');
Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves', key: Key('appbar-title'));
if (AvesApp.mode == AppMode.main) {
title = SourceStateAwareAppBarTitle(
title: title,
@ -168,6 +170,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return [
if (collection.isBrowsing)
IconButton(
key: Key('search-button'),
icon: Icon(AIcons.search),
onPressed: _goToSearch,
),
@ -184,15 +187,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
)),
Builder(
builder: (context) => PopupMenuButton<CollectionAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
final hasSelection = collection.selection.isNotEmpty;
return [
PopupMenuItem(
key: Key('menu-sort'),
value: CollectionAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
if (collection.sortFactor == SortFactor.date)
PopupMenuItem(
key: Key('menu-group'),
value: CollectionAction.group,
child: MenuRow(text: 'Group...', icon: AIcons.group),
),

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';

View file

@ -104,6 +104,7 @@ class ExpandableFilterRow extends StatelessWidget {
Widget _buildFilterChip(CollectionFilter filter) {
return AvesFilterChip(
key: Key(filter.key),
filter: filter,
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
onPressed: onPressed,

View file

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart';

View file

@ -126,6 +126,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
expectedContentLength: entry.sizeBytes,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {

View file

@ -18,7 +18,7 @@ import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug_page.dart';
import 'package:aves/widgets/filter_grid_page.dart';
import 'package:aves/widgets/settings_page.dart';
import 'package:aves/widgets/settings/settings_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -200,6 +200,7 @@ class _AppDrawerState extends State<AppDrawer> {
top: false,
bottom: false,
child: ListTile(
key: Key('albums-tile'),
leading: Icon(AIcons.album),
title: Text('Albums'),
trailing: StreamBuilder(

View file

@ -76,12 +76,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _print(ImageEntry entry) async {
final uri = entry.uri;
final mimeType = entry.mimeType;
final orientationDegrees = entry.orientationDegrees;
final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName);
PdfImage pdfImage;
if (entry.isSvg) {
final bytes = await ImageFileService.getImage(uri, mimeType);
final bytes = await ImageFileService.getImage(uri, mimeType, orientationDegrees: entry.orientationDegrees);
if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri);
final viewBox = svgRoot.viewport.viewBox;
@ -97,7 +98,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
} else {
pdfImage = await pdfImageFromImageProvider(
pdf: doc.document,
image: UriImage(uri: uri, mimeType: mimeType),
image: UriImage(
uri: uri,
mimeType: mimeType,
orientationDegrees: orientationDegrees,
),
);
}
if (pdfImage != null) {

View file

@ -1,5 +1,5 @@
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -35,6 +35,7 @@ class _GroupCollectionDialogState extends State<GroupCollectionDialog> {
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedGroup),
child: Text('Apply'.toUpperCase()),
),
@ -42,8 +43,9 @@ class _GroupCollectionDialogState extends State<GroupCollectionDialog> {
);
}
Widget _buildRadioListTile(GroupFactor group, String title) => RadioListTile<GroupFactor>(
value: group,
Widget _buildRadioListTile(GroupFactor value, String title) => RadioListTile<GroupFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedGroup,
onChanged: (group) => setState(() => _selectedGroup = group),
title: Text(

View file

@ -1,5 +1,5 @@
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -34,6 +34,7 @@ class _SortCollectionDialogState extends State<SortCollectionDialog> {
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedSort),
child: Text('Apply'.toUpperCase()),
),
@ -41,8 +42,9 @@ class _SortCollectionDialogState extends State<SortCollectionDialog> {
);
}
Widget _buildRadioListTile(SortFactor sort, String title) => RadioListTile<SortFactor>(
value: sort,
Widget _buildRadioListTile(SortFactor value, String title) => RadioListTile<SortFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedSort,
onChanged: (sort) => setState(() => _selectedSort = sort),
title: Text(

View file

@ -35,6 +35,7 @@ class AIcons {
static const IconData goUp = OMIcons.arrowUpward;
static const IconData group = OMIcons.groupWork;
static const IconData info = OMIcons.info;
static const IconData layers = OMIcons.layers;
static const IconData openInNew = OMIcons.openInNew;
static const IconData print = OMIcons.print;
static const IconData refresh = OMIcons.refresh;
@ -46,7 +47,6 @@ class AIcons {
static const IconData share = OMIcons.share;
static const IconData sort = OMIcons.sort;
static const IconData stats = OMIcons.pieChart;
static const IconData style = OMIcons.palette;
static const IconData zoomIn = OMIcons.add;
static const IconData zoomOut = OMIcons.remove;

View file

@ -11,13 +11,14 @@ class UriImage extends ImageProvider<UriImage> {
const UriImage({
@required this.uri,
@required this.mimeType,
@required this.orientationDegrees,
this.expectedContentLength,
this.scale = 1.0,
}) : assert(uri != null),
assert(scale != null);
final String uri, mimeType;
final int expectedContentLength;
final int orientationDegrees, expectedContentLength;
final double scale;
@override
@ -46,6 +47,7 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage(
uri,
mimeType,
orientationDegrees: orientationDegrees,
expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(

View file

@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class FullscreenDebugPage extends StatefulWidget {
final ImageEntry entry;
@ -23,7 +24,9 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader;
int get contentId => widget.entry.contentId;
ImageEntry get entry => widget.entry;
int get contentId => entry.contentId;
@override
void initState() {
@ -33,32 +36,102 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
@override
Widget build(BuildContext context) {
final tabs = <Tuple2<Tab, Widget>>[
Tuple2(Tab(text: 'Entry'), _buildEntryTabView()),
Tuple2(Tab(text: 'DB'), _buildDbTabView()),
Tuple2(Tab(text: 'Content Resolver'), _buildContentResolverTabView()),
];
return DefaultTabController(
length: 2,
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: Text('Debug'),
bottom: TabBar(
tabs: [
Tab(text: 'DB'),
Tab(text: 'Content Resolver'),
],
tabs: tabs.map((t) => t.item1).toList(),
),
),
body: SafeArea(
child: TabBarView(
children: [
_buildDbTabView(),
_buildContentResolverTabView(),
],
children: tabs.map((t) => t.item2).toList(),
),
),
),
);
}
Widget _buildEntryTabView() {
String toDateValue(int time, {int factor = 1}) {
var value = '$time';
if (time != null && time > 0) {
value += ' (${DateTime.fromMillisecondsSinceEpoch(time * factor)})';
}
return value;
}
return ListView(
padding: EdgeInsets.all(16),
children: [
InfoRowGroup({
'uri': '${entry.uri}',
'contentId': '${entry.contentId}',
'path': '${entry.path}',
'directory': '${entry.directory}',
'filenameWithoutExtension': '${entry.filenameWithoutExtension}',
'sourceTitle': '${entry.sourceTitle}',
'sourceMimeType': '${entry.sourceMimeType}',
'mimeType': '${entry.mimeType}',
'mimeTypeAnySubtype': '${entry.mimeTypeAnySubtype}',
}),
Divider(),
InfoRowGroup({
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
'bestDate': '${entry.bestDate}',
'monthTaken': '${entry.monthTaken}',
'dayTaken': '${entry.dayTaken}',
}),
Divider(),
InfoRowGroup({
'width': '${entry.width}',
'height': '${entry.height}',
'orientationDegrees': '${entry.orientationDegrees}',
'rotated': '${entry.rotated}',
'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}',
'megaPixels': '${entry.megaPixels}',
}),
Divider(),
InfoRowGroup({
'durationMillis': '${entry.durationMillis}',
'durationText': '${entry.durationText}',
}),
Divider(),
InfoRowGroup({
'sizeBytes': '${entry.sizeBytes}',
'isFavourite': '${entry.isFavourite}',
'isSvg': '${entry.isSvg}',
'isPhoto': '${entry.isPhoto}',
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}',
'canEdit': '${entry.canEdit}',
'canPrint': '${entry.canPrint}',
'canRotate': '${entry.canRotate}',
'xmpSubjects': '${entry.xmpSubjects}',
}),
Divider(),
InfoRowGroup({
'hasGps': '${entry.hasGps}',
'isLocated': '${entry.isLocated}',
'latLng': '${entry.latLng}',
'geoUri': '${entry.geoUri}',
}),
],
);
}
Widget _buildDbTabView() {
final catalog = widget.entry.catalogMetadata;
final catalog = entry.catalogMetadata;
return ListView(
padding: EdgeInsets.all(16),
children: [
@ -185,7 +258,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(widget.entry);
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
setState(() {});
}
}

View file

@ -308,6 +308,13 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
}
void _updateEntry() {
if (_currentHorizontalPage != null && entries.isNotEmpty && _currentHorizontalPage >= entries.length) {
// as of Flutter v1.20.2, `PageView` does not call `onPageChanged` when the last page is deleted
// so we manually track the page change, and let the entry update follow
_onHorizontalPageChanged(entries.length - 1);
return;
}
final newEntry = _currentHorizontalPage != null && _currentHorizontalPage < entries.length ? entries[_currentHorizontalPage] : null;
if (_entryNotifier.value == newEntry) return;
_entryNotifier.value = newEntry;
@ -492,6 +499,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
child: child,
),
child: PageView(
key: Key('vertical-pageview'),
scrollDirection: Axis.vertical,
controller: widget.verticalPager,
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
@ -522,7 +530,11 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
// when the entry image itself changed (e.g. after rotation)
void _onImageChanged() async {
await UriImage(uri: entry.uri, mimeType: entry.mimeType).evict();
await UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
).evict();
// evict low quality thumbnail (without specified extents)
await ThumbnailProvider(entry: entry).evict();
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)

View file

@ -37,6 +37,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
return PhotoViewGestureDetectorScope(
axis: [Axis.horizontal, Axis.vertical],
child: PageView.builder(
key: Key('horizontal-pageview'),
scrollDirection: Axis.horizontal,
controller: widget.pageController,
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
@ -45,6 +46,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
final entry = entries[index];
return ClipRect(
child: ImageView(
key: Key('imageview'),
entry: entry,
heroTag: widget.collection.heroTag(entry),
onScaleChanged: widget.onScaleChanged,

View file

@ -20,12 +20,13 @@ class ImageView extends StatelessWidget {
final List<Tuple2<String, IjkMediaController>> videoControllers;
const ImageView({
Key key,
this.entry,
this.heroTag,
this.onScaleChanged,
this.onTap,
this.videoControllers,
});
}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -95,6 +96,7 @@ class ImageView extends StatelessWidget {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
expectedContentLength: entry.sizeBytes,
);
child = PhotoView(

View file

@ -40,6 +40,7 @@ class InfoPageState extends State<InfoPage> {
final appBar = SliverAppBar(
leading: IconButton(
key: Key('back-button'),
icon: Icon(AIcons.goUp),
onPressed: _goToImage,
tooltip: 'Back to image',
@ -177,7 +178,7 @@ class SectionRow extends StatelessWidget {
children: [
buildDivider(),
Padding(
padding: EdgeInsets.all(16.0),
padding: EdgeInsets.all(16),
child: Icon(
icon,
size: dim,

View file

@ -2,7 +2,6 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
@ -83,24 +82,18 @@ class _LocationSectionState extends State<LocationSection> {
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country;${address.countryCode}'));
final place = address.place;
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
} else if (entry.hasGps) {
location = toDMS(entry.latLng).join(', ');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showTitle)
Padding(
padding: EdgeInsets.only(bottom: 8),
child: SectionRow(AIcons.location),
),
if (widget.showTitle) SectionRow(AIcons.location),
NotificationListener(
onNotification: (notification) {
if (notification is MapStyleChangedNotification) setState(() {});
return false;
},
child: settings.infoMapStyle == EntryMapStyle.google
child: settings.infoMapStyle.isGoogleMaps
? EntryGoogleMap(
markerId: entry.uri ?? entry.path,
latLng: entry.latLng,
@ -114,11 +107,8 @@ class _LocationSectionState extends State<LocationSection> {
style: settings.infoMapStyle,
),
),
if (location.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 8),
child: InfoRowGroup({'Address': location}),
),
if (entry.hasGps) InfoRowGroup({'Coordinates': settings.coordinateFormat.format(entry.latLng)}),
if (location.isNotEmpty) InfoRowGroup({'Address': location}),
if (filters.isNotEmpty)
Padding(
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
@ -145,13 +135,17 @@ class _LocationSectionState extends State<LocationSection> {
}
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
enum EntryMapStyle { google, osmHot, stamenToner, stamenWatercolor }
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
extension ExtraEntryMapStyle on EntryMapStyle {
String get name {
switch (this) {
case EntryMapStyle.google:
case EntryMapStyle.googleNormal:
return 'Google Maps';
case EntryMapStyle.googleHybrid:
return 'Google Maps (Hybrid)';
case EntryMapStyle.googleTerrain:
return 'Google Maps (Terrain)';
case EntryMapStyle.osmHot:
return 'Humanitarian OpenStreetMap';
case EntryMapStyle.stamenToner:
@ -162,4 +156,15 @@ extension ExtraEntryMapStyle on EntryMapStyle {
return toString();
}
}
bool get isGoogleMaps {
switch (this) {
case EntryMapStyle.googleNormal:
case EntryMapStyle.googleHybrid:
case EntryMapStyle.googleTerrain:
return true;
default:
return false;
}
}
}

View file

@ -59,7 +59,13 @@ class MapButtonPanel extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: AIcons.style,
icon: AIcons.openInNew,
onPressed: () => AndroidAppService.openMap(geoUri),
tooltip: 'Show on map...',
),
SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.layers,
onPressed: () async {
final style = await showDialog<EntryMapStyle>(
context: context,
@ -72,12 +78,6 @@ class MapButtonPanel extends StatelessWidget {
},
tooltip: 'Style map...',
),
SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.openInNew,
onPressed: () => AndroidAppService.openMap(geoUri),
tooltip: 'Show on map...',
),
Spacer(),
MapOverlayButton(
icon: AIcons.zoomIn,

View file

@ -1,4 +1,5 @@
import 'package:aves/model/settings.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:aves/widgets/fullscreen/info/maps/common.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
@ -67,6 +68,7 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
onMapCreated: (controller) => setState(() => _controller = controller),
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapStyle(settings.infoMapStyle),
rotateGesturesEnabled: false,
scrollGesturesEnabled: false,
zoomControlsEnabled: false,
@ -91,6 +93,19 @@ class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveC
_controller.animateCamera(CameraUpdate.zoomBy(amount));
}
MapType _toMapStyle(EntryMapStyle style) {
switch (style) {
case EntryMapStyle.googleNormal:
return MapType.normal;
case EntryMapStyle.googleHybrid:
return MapType.hybrid;
case EntryMapStyle.googleTerrain:
return MapType.terrain;
default:
return MapType.none;
}
}
@override
bool get wantKeepAlive => true;
}

View file

@ -116,17 +116,19 @@ class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliv
Widget _buildAttribution() {
switch (widget.style) {
case EntryMapStyle.osmHot:
return _buildAttributionMarkdown('© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, Tiles style by [Humanitarian OpenStreetMap Team](https://www.hotosm.org/) hosted by [OpenStreetMap France](https://openstreetmap.fr/)');
return _buildAttributionMarkdown('Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, tiles by [Humanitarian OpenStreetMap Team](https://www.hotosm.org/) hosted by [OpenStreetMap France](https://openstreetmap.fr/)');
case EntryMapStyle.stamenToner:
case EntryMapStyle.stamenWatercolor:
return _buildAttributionMarkdown('Map tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0) — Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors');
return _buildAttributionMarkdown('Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)');
default:
return SizedBox.shrink();
}
}
Widget _buildAttributionMarkdown(String data) {
return Markdown(
return Padding(
padding: EdgeInsets.only(top: 4),
child: MarkdownBody(
data: data,
selectable: true,
styleSheet: MarkdownStyleSheet(
@ -138,8 +140,7 @@ class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliv
await launch(url);
}
},
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
shrinkWrap: true,
),
);
}

View file

@ -87,6 +87,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
accentColor: Colors.white,
),
child: ExpansionTileCard(
key: Key('tilecard-${dir.name}'),
value: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
title: _DirectoryTitle(dir.name),

View file

@ -3,9 +3,9 @@ import 'dart:ui';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/overlay/common.dart';
@ -212,7 +212,7 @@ class _LocationRow extends AnimatedWidget {
if (entry.isLocated) {
location = entry.shortAddress;
} else if (entry.hasGps) {
location = toDMS(entry.latLng).join(', ');
location = settings.coordinateFormat.format(entry.latLng);
}
return Row(
children: [

View file

@ -133,6 +133,7 @@ class _TopOverlayRow extends StatelessWidget {
OverlayButton(
scale: scale,
child: PopupMenuButton<EntryAction>(
key: Key('entry-menu-button'),
itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem),
PopupMenuDivider(),

View file

@ -101,6 +101,7 @@ class AvesVideoState extends State<AvesVideo> {
image: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
expectedContentLength: entry.sizeBytes,
),
width: entry.width.toDouble(),

View file

@ -0,0 +1,31 @@
import 'package:aves/model/settings.dart';
import 'package:flutter/material.dart';
class CoordinateFormatSelector extends StatefulWidget {
@override
_CoordinateFormatSelectorState createState() => _CoordinateFormatSelectorState();
}
class _CoordinateFormatSelectorState extends State<CoordinateFormatSelector> {
@override
Widget build(BuildContext context) {
return DropdownButton<CoordinateFormat>(
items: CoordinateFormat.values
.map((selected) => DropdownMenuItem(
value: selected,
child: Text(
selected.name,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: settings.coordinateFormat,
onChanged: (selected) {
settings.coordinateFormat = selected;
setState(() {});
},
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:aves/model/settings.dart';
import 'package:flutter/material.dart';
class LaunchPageSelector extends StatefulWidget {
@override
_LaunchPageSelectorState createState() => _LaunchPageSelectorState();
}
class _LaunchPageSelectorState extends State<LaunchPageSelector> {
@override
Widget build(BuildContext context) {
return DropdownButton<LaunchPage>(
items: LaunchPage.values
.map((selected) => DropdownMenuItem(
value: selected,
child: Text(
selected.name,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: settings.launchPage,
onChanged: (selected) {
settings.launchPage = selected;
setState(() {});
},
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:aves/model/settings.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/coordinate_format.dart';
import 'package:aves/widgets/settings/launch_page.dart';
import 'package:flutter/material.dart';
class SettingsPage extends StatelessWidget {
@ -21,11 +22,19 @@ class SettingsPage extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Launch Page:'),
Text('Launch page:'),
SizedBox(width: 8),
Flexible(child: LaunchPageSelector()),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Coordinate format:'),
SizedBox(width: 8),
Flexible(child: CoordinateFormatSelector()),
],
),
],
),
),
@ -34,32 +43,3 @@ class SettingsPage extends StatelessWidget {
);
}
}
class LaunchPageSelector extends StatefulWidget {
@override
_LaunchPageSelectorState createState() => _LaunchPageSelectorState();
}
class _LaunchPageSelectorState extends State<LaunchPageSelector> {
@override
Widget build(BuildContext context) {
return DropdownButton<LaunchPage>(
items: LaunchPage.values
.map((selected) => DropdownMenuItem(
value: selected,
child: Text(
selected.name,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: settings.launchPage,
onChanged: (selected) {
settings.launchPage = selected;
setState(() {});
},
);
}
}

View file

@ -2,11 +2,13 @@ import 'dart:math';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/icons.dart';
@ -112,30 +114,25 @@ class StatsPage extends StatelessWidget {
);
}
String _cleanMime(String mime) {
mime = mime.toUpperCase().replaceFirst(RegExp('.*/(X-)?'), '').replaceFirst('+XML', '').replaceFirst('VND.', '');
return mime;
}
Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map<String, num> byMimeTypes) {
if (byMimeTypes.isEmpty) return SizedBox.shrink();
final sum = byMimeTypes.values.fold<int>(0, (prev, v) => prev + v);
final seriesData = byMimeTypes.entries.map((kv) => StringNumDatum(_cleanMime(kv.key), kv.value)).toList();
seriesData.sort((kv1, kv2) {
final c = kv2.value.compareTo(kv1.value);
return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key);
final seriesData = byMimeTypes.entries.map((kv) => EntryByMimeDatum(mimeType: kv.key, entryCount: kv.value)).toList();
seriesData.sort((d1, d2) {
final c = d2.entryCount.compareTo(d1.entryCount);
return c != 0 ? c : compareAsciiUpperCase(d1.displayText, d2.displayText);
});
final series = [
charts.Series<StringNumDatum, String>(
charts.Series<EntryByMimeDatum, String>(
id: 'mime',
colorFn: (d, i) => charts.ColorUtil.fromDartColor(stringToColor(d.key)),
domainFn: (d, i) => d.key,
measureFn: (d, i) => d.value,
colorFn: (d, i) => charts.ColorUtil.fromDartColor(d.color),
domainFn: (d, i) => d.displayText,
measureFn: (d, i) => d.entryCount,
data: seriesData,
labelAccessorFn: (d, _) => '${d.key}: ${d.value}',
labelAccessorFn: (d, _) => '${d.displayText}: ${d.entryCount}',
),
];
@ -171,23 +168,26 @@ class StatsPage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: seriesData
.map((kv) => Text.rich(
.map((d) => GestureDetector(
onTap: () => _goToCollection(context, MimeFilter(d.mimeType)),
child: Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsetsDirectional.only(end: 8),
child: Icon(AIcons.disc, color: stringToColor(kv.key)),
child: Icon(AIcons.disc, color: d.color),
),
),
TextSpan(text: '${kv.key} '),
TextSpan(text: '${kv.value}', style: TextStyle(color: Colors.white70)),
TextSpan(text: '${d.displayText} '),
TextSpan(text: '${d.entryCount}', style: TextStyle(color: Colors.white70)),
],
),
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
))
.toList(),
),
@ -230,16 +230,33 @@ class StatsPage extends StatelessWidget {
),
];
}
void _goToCollection(BuildContext context, CollectionFilter filter) {
if (collection == null) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(collection.derive(filter)),
),
(route) => false,
);
}
}
class StringNumDatum {
final String key;
final num value;
class EntryByMimeDatum {
final String mimeType;
final String displayText;
final int entryCount;
const StringNumDatum(this.key, this.value);
EntryByMimeDatum({
@required this.mimeType,
@required this.entryCount,
}) : displayText = MimeFilter.displayType(mimeType);
Color get color => stringToColor(displayText);
@override
String toString() {
return '[$runtimeType#$hashCode: key=$key, value=$value]';
return '[$runtimeType#$hashCode: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]';
}
}

View file

@ -93,11 +93,13 @@ class _WelcomePageState extends State<WelcomePage> {
List<Widget> _buildBottomControls(BuildContext context) {
final checkbox = LabeledCheckbox(
key: Key('agree-checkbox'),
value: _hasAcceptedTerms,
onChanged: (v) => setState(() => _hasAcceptedTerms = v),
text: 'I agree to the terms and conditions',
);
final button = RaisedButton(
key: Key('continue-button'),
child: Text('Continue'),
onPressed: _hasAcceptedTerms
? () {

View file

@ -1,6 +1,20 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "7.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "0.39.17"
ansicolor:
dependency: transitive
description:
@ -78,6 +92,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.0"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
@ -106,6 +127,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
coverage:
dependency: transitive
description:
name: coverage
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0"
crypto:
dependency: transitive
description:
@ -113,6 +141,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.2"
draggable_scrollbar:
dependency: "direct main"
description:
@ -178,6 +213,11 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.1"
flutter_driver:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_ijkplayer:
dependency: "direct main"
description:
@ -246,6 +286,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
geocoder:
dependency: "direct main"
description:
@ -253,6 +298,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1"
glob:
dependency: transitive
description:
name: glob
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
google_maps_flutter:
dependency: "direct main"
description:
@ -267,6 +319,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
html:
dependency: transitive
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0+3"
http:
dependency: transitive
description:
@ -274,6 +333,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.2"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
http_parser:
dependency: transitive
description:
@ -295,6 +361,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.16.1"
io:
dependency: transitive
description:
name: io
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.4"
js:
dependency: transitive
description:
@ -302,6 +375,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
json_rpc_2:
dependency: transitive
description:
name: json_rpc_2
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
latlong:
dependency: "direct main"
description:
@ -351,6 +431,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7"
nested:
dependency: transitive
description:
@ -358,6 +445,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4"
node_interop:
dependency: transitive
description:
name: node_interop
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
node_io:
dependency: transitive
description:
name: node_io
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
node_preamble:
dependency: transitive
description:
name: node_preamble
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.12"
outline_material_icons:
dependency: "direct main"
description:
@ -365,6 +473,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.1"
package_config:
dependency: transitive
description:
name: package_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.3"
package_info:
dependency: "direct main"
description:
@ -500,6 +615,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
pool:
dependency: transitive
description:
name: pool
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
positioned_tap_detector:
dependency: transitive
description:
@ -605,11 +727,53 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2+7"
shelf:
dependency: transitive
description:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.9"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
shelf_static:
dependency: transitive
description:
name: shelf_static
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.8"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
source_maps:
dependency: transitive
description:
name: source_maps
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.9"
source_span:
dependency: transitive
description:
@ -666,6 +830,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
sync_http:
dependency: transitive
description:
name: sync_http
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
synchronized:
dependency: transitive
description:
@ -680,6 +851,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
test:
dependency: "direct dev"
description:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.2"
test_api:
dependency: transitive
description:
@ -687,6 +865,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.17"
test_core:
dependency: transitive
description:
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.10"
transparent_image:
dependency: transitive
description:
@ -778,6 +963,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
vm_service:
dependency: transitive
description:
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
vm_service_client:
dependency: transitive
description:
name: vm_service_client
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.6+2"
watcher:
dependency: transitive
description:
name: watcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.7+15"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
webdriver:
dependency: transitive
description:
name: webdriver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.3"
wkt_parser:
dependency: transitive
description:
@ -799,6 +1026,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
yaml:
dependency: transitive
description:
name: yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
sdks:
dart: ">=2.9.0-14.0.dev <3.0.0"
flutter: ">=1.18.0-6.0.pre <2.0.0"

View file

@ -1,5 +1,9 @@
name: aves
description: A new Flutter application.
description: Aves is a gallery and metadata explorer app, built for Android.
# The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@ -11,7 +15,7 @@ description: A new Flutter application.
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.6+18
version: 1.1.7+19
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)
@ -83,8 +87,16 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter:
# run on any device:
# % flutter drive -t test_driver/app.dart
# capture shaders in profile mode (real device only):
# % flutter drive -t test_driver/app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json
flutter_driver:
sdk: flutter
test: any
flutter:
uses-material-design: true
assets:

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,12 @@
import 'package:aves/utils/geo_utils.dart';
import 'package:test/test.dart';
import 'package:tuple/tuple.dart';
void main() {
test('Decimal degrees to DMS (sexagesimal)', () {
expect(toDMS(Tuple2(37.496667, 127.0275)), ['37° 29 48.00″ N', '127° 1 39.00″ E']); // Gangnam
expect(toDMS(Tuple2(78.9243503, 11.9230465)), ['78° 55 27.66″ N', '11° 55 22.97″ E']); // Ny-Ålesund
expect(toDMS(Tuple2(-38.6965891, 175.9830047)), ['38° 41 47.72″ S', '175° 58 58.82″ E']); // Taupo
expect(toDMS(Tuple2(-64.249391, -56.6556145)), ['64° 14 57.81″ S', '56° 39 20.21″ W']); // Marambio
});
}

18
test_driver/app.dart Normal file
View file

@ -0,0 +1,18 @@
import 'package:aves/main.dart' as app;
import 'package:aves/services/android_file_service.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
import 'constants.dart';
void main() {
enableFlutterDriverExtension();
// scan files copied from test assets
// we do it via the app instead of broadcasting via ADB
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
unawaited(AndroidFileService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'));
app.main();
}

194
test_driver/app_test.dart Normal file
View file

@ -0,0 +1,194 @@
import 'package:aves/model/source/enums.dart';
import 'package:flutter_driver/flutter_driver.dart';
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
import 'package:test/test.dart';
import 'constants.dart';
import 'utils/adb_utils.dart';
import 'utils/driver_extension.dart';
FlutterDriver driver;
void main() {
group('Aves app', () {
print('adb=${[adb, ...adbDeviceParam].join(' ')}');
setUpAll(() async {
await copyContent(sourcePicturesDir, targetPicturesDir);
await grantPermissions('deckers.thibault.aves.debug', [
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.WRITE_EXTERNAL_STORAGE',
'android.permission.ACCESS_MEDIA_LOCATION',
]);
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
await removeDirectory(targetPicturesDir);
unawaited(driver?.close());
});
agreeToTerms();
sortCollection();
groupCollection();
selectFirstAlbum();
searchAlbum();
showFullscreen();
toggleOverlay();
zoom();
showInfoMetadata();
scrollOffImage();
test('contemplation', () async {
await Future.delayed(Duration(seconds: 5));
});
}, timeout: Timeout(Duration(seconds: 10)));
}
void agreeToTerms() {
test('[welcome] agree to terms', () async {
await driver.scroll(find.text('Terms of Service'), 0, -300, Duration(milliseconds: 500));
await driver.tap(find.byValueKey('agree-checkbox'));
await Future.delayed(Duration(seconds: 1));
await driver.tap(find.byValueKey('continue-button'));
await driver.waitUntilNoTransientCallbacks();
expect(await driver.getText(find.byValueKey('appbar-title')), 'Aves');
});
}
void groupCollection() {
test('[collection] group', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-group'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(GroupFactor.album.toString()));
await driver.tap(find.byValueKey('apply-button'));
});
}
void sortCollection() {
test('[collection] sort', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-sort'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(SortFactor.date.toString()));
await driver.tap(find.byValueKey('apply-button'));
});
}
void selectFirstAlbum() {
test('[collection] select first album', () async {
await driver.tap(find.byValueKey('appbar-leading-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('albums-tile'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.descendant(
of: find.byType('FilterGridPage'),
matching: find.byType('DecoratedFilterChip'),
firstMatchOnly: true,
));
await driver.waitUntilNoTransientCallbacks();
});
}
void searchAlbum() {
test('[collection] search album', () async {
await driver.tap(find.byValueKey('search-button'));
await driver.waitUntilNoTransientCallbacks();
final album = path.split(targetPicturesDir).last;
await driver.tap(find.byType('TextField'));
await driver.enterText(album);
final albumChip = find.byValueKey('album-$album');
await driver.waitFor(albumChip);
await driver.tap(albumChip);
});
}
void showFullscreen() {
test('[collection] show fullscreen', () async {
await driver.tap(find.byType('DecoratedThumbnail'));
await driver.waitUntilNoTransientCallbacks();
await Future.delayed(Duration(seconds: 2));
});
}
void toggleOverlay() {
test('[fullscreen] toggle overlay', () async {
final imageView = find.byValueKey('imageview');
print('* hide overlay');
await driver.tap(imageView);
await Future.delayed(Duration(seconds: 1));
print('* show overlay');
await driver.tap(imageView);
await Future.delayed(Duration(seconds: 1));
});
}
void zoom() {
test('[fullscreen] zoom cycle', () async {
final imageView = find.byValueKey('imageview');
await driver.doubleTap(imageView);
await Future.delayed(Duration(seconds: 1));
await driver.doubleTap(imageView);
await Future.delayed(Duration(seconds: 1));
await driver.doubleTap(imageView);
await Future.delayed(Duration(seconds: 1));
});
}
void showInfoMetadata() {
test('[fullscreen] show info', () async {
final verticalPageView = find.byValueKey('vertical-pageview');
print('* scroll down to info');
await driver.scroll(verticalPageView, 0, -600, Duration(milliseconds: 400));
await Future.delayed(Duration(seconds: 2));
print('* scroll down to metadata details');
await driver.scroll(verticalPageView, 0, -800, Duration(milliseconds: 600));
await Future.delayed(Duration(seconds: 1));
print('* toggle GPS metadata');
final gpsTile = find.descendant(
of: find.byValueKey('tilecard-GPS'),
matching: find.byType('ListTile'),
);
await driver.tap(gpsTile);
await driver.waitUntilNoTransientCallbacks();
await driver.tap(gpsTile);
await driver.waitUntilNoTransientCallbacks();
print('* scroll up to show app bar');
await driver.scroll(verticalPageView, 0, 100, Duration(milliseconds: 400));
await Future.delayed(Duration(seconds: 1));
print('* back to image');
await driver.tap(find.byValueKey('back-button'));
});
}
void scrollOffImage() {
test('[fullscreen] scroll off', () async {
await driver.scroll(find.byValueKey('imageview'), 0, 800, Duration(milliseconds: 600));
await Future.delayed(Duration(seconds: 1));
});
}

BIN
test_driver/assets/ipse.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 MiB

View file

@ -0,0 +1,2 @@
const sourcePicturesDir = 'test_driver/assets/';
const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/';

View file

@ -0,0 +1,43 @@
import 'dart:io';
import 'package:path/path.dart' as path;
String get adb {
final env = Platform.environment;
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK'];
return path.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
}
/*
If there's only one emulator running or only one device connected,
the adb command is sent to that device by default.
If multiple emulators are running and/or multiple devices are attached,
you need to use the -d, -e, or -s option to specify the target device
to which the command should be directed.
*/
const List<String> adbDeviceParam = []; // '[]', '[-d]', '[-e]', or '[-s, <serial_number>]'
Future<void> runAdb(List<String> args) async {
await Process.runSync(adb, [...adbDeviceParam, ...args]);
}
Future<void> createDirectory(String dir) async {
await runAdb(['shell', 'mkdir', '-p', dir.replaceAll(' ', '\\ ')]);
}
Future<void> removeDirectory(String dir) async {
await runAdb(['shell', 'rm', '-r', dir.replaceAll(' ', '\\ ')]);
}
Future<void> copyContent(String sourceDir, String targetDir) async {
// to copy the content of `source` inside `target`
// `push source/* target/` works only when the target directory exists, and fails when `target` contains spaces
// `push source/ target/` works only when the target directory does not exist
await removeDirectory(targetDir);
await runAdb(['push', sourceDir, targetDir]);
}
// only works in debug mode
Future<void> grantPermissions(String packageName, Iterable<String> permissions) async {
await Future.forEach(permissions, (permission) => runAdb(['shell', 'pm', 'grant', packageName, permission]));
}

View file

@ -0,0 +1,11 @@
import 'package:flutter_driver/flutter_driver.dart';
extension ExtraFlutterDriver on FlutterDriver {
static const doubleTapDelay = Duration(milliseconds: 100); // in [kDoubleTapMinTime = 40 ms, kDoubleTapTimeout = 300 ms]
Future doubleTap(SerializableFinder finder, {Duration timeout}) async {
await tap(finder, timeout: timeout);
await Future.delayed(doubleTapDelay);
await tap(finder, timeout: timeout);
}
}