Merge branch 'develop'
This commit is contained in:
commit
3b4aad8d22
66 changed files with 1107 additions and 304 deletions
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
@ -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
8
.gitignore
vendored
|
@ -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
|
||||
|
|
12
README.md
12
README.md
|
@ -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
5
android/.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
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);
|
||||
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());
|
||||
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());
|
||||
} 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)) {
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.enableR8=true
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -44,7 +44,7 @@ class CatalogMetadata {
|
|||
this.xmpTitleDescription,
|
||||
double latitude,
|
||||
double longitude,
|
||||
})
|
||||
})
|
||||
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
|
||||
: latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
|
||||
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 = [];
|
||||
|
||||
|
|
5
lib/model/source/enums.dart
Normal file
5
lib/model/source/enums.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
enum SortFactor { date, size, name }
|
||||
|
||||
enum GroupFactor { none, album, month, day }
|
||||
|
||||
enum Activity { browse, select }
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,22 +16,15 @@ 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']
|
||||
// return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
List<String> toDMS(Tuple2<double, double> latLng) {
|
||||
if (latLng == null) return [];
|
||||
final lat = latLng.item1;
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(() {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -116,30 +116,31 @@ 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(
|
||||
data: data,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
a: TextStyle(color: Theme.of(context).accentColor),
|
||||
p: TextStyle(color: Colors.white70, fontSize: 13, fontFamily: 'Concourse'),
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
child: MarkdownBody(
|
||||
data: data,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
a: TextStyle(color: Theme.of(context).accentColor),
|
||||
p: TextStyle(color: Colors.white70, fontSize: 13, fontFamily: 'Concourse'),
|
||||
),
|
||||
onTapLink: (url) async {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url);
|
||||
}
|
||||
},
|
||||
),
|
||||
onTapLink: (url) async {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url);
|
||||
}
|
||||
},
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||
shrinkWrap: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
|
|
31
lib/widgets/settings/coordinate_format.dart
Normal file
31
lib/widgets/settings/coordinate_format.dart
Normal 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(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
31
lib/widgets/settings/launch_page.dart
Normal file
31
lib/widgets/settings/launch_page.dart
Normal 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(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 8),
|
||||
child: Icon(AIcons.disc, color: stringToColor(kv.key)),
|
||||
.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: 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,
|
||||
),
|
||||
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]';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
? () {
|
||||
|
|
234
pubspec.lock
234
pubspec.lock
|
@ -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"
|
||||
|
|
18
pubspec.yaml
18
pubspec.yaml
|
@ -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
12
test/utils/geo_utils_test.dart
Normal file
12
test/utils/geo_utils_test.dart
Normal 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
18
test_driver/app.dart
Normal 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
194
test_driver/app_test.dart
Normal 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
BIN
test_driver/assets/ipse.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 5 MiB |
2
test_driver/constants.dart
Normal file
2
test_driver/constants.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
const sourcePicturesDir = 'test_driver/assets/';
|
||||
const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/';
|
43
test_driver/utils/adb_utils.dart
Normal file
43
test_driver/utils/adb_utils.dart
Normal 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]));
|
||||
}
|
11
test_driver/utils/driver_extension.dart
Normal file
11
test_driver/utils/driver_extension.dart
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue