Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-07-26 12:24:13 +09:00
commit ce0808fddf
84 changed files with 1023 additions and 814 deletions

View file

@ -1,4 +1,4 @@
name: Release an APK and an App Bundle on tagging name: Release on tag
on: on:
push: push:
@ -33,9 +33,11 @@ jobs:
working-directory: ${{ github.workspace }}/scripts working-directory: ${{ github.workspace }}/scripts
run: ./update_flutter_version.sh run: ./update_flutter_version.sh
# `flutter test` fails if test directory is missing - name: Static analysis.
#- name: Run the unit tests. run: flutter analyze
# run: flutter test
- name: Unit tests.
run: flutter test
- name: Build signed artifacts. - name: Build signed artifacts.
# `KEY_JKS` should contain the result of: # `KEY_JKS` should contain the result of:
@ -86,4 +88,4 @@ jobs:
packageName: deckers.thibault.aves packageName: deckers.thibault.aves
releaseFile: app-release.aab releaseFile: app-release.aab
track: beta track: beta
whatsNewDirectory: whatsnew whatsNewDirectory: whatsnew

View file

@ -1,7 +1,15 @@
![Aves logo][] [<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt='Get it on Google Play' width="200">](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) ![Version badge][Version badge]
![Build badge][Build badge]
<br />
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/assets/aves_logo.svg" alt='Aves logo' width="200" />
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt='Get it on Google Play' width="200">](https://play.google.com/store/apps/details?id=deckers.thibault.aves&pcampaignid=pcampaignidMKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1)
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter. Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/1-S10-collection.jpg" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/2-S10-image.jpg" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.0.0/S10/5-S10-stats.jpg" alt='Stats screenshot' height="400" />
## Features ## Features
- support raster images: JPEG, PNG, GIF, WEBP, BMP, WBMP, HEIC (from Android Pie) - support raster images: JPEG, PNG, GIF, WEBP, BMP, WBMP, HEIC (from Android Pie)
@ -13,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- favorites - favorites
- statistics - statistics
- handle intents to view or pick images - handle intents to view or pick images
- support Android API 24 ~ 29 (Nougat ~ Android 10) - support Android API 24 ~ 30 (Nougat ~ R)
## Roadmap ## Roadmap
@ -25,7 +33,6 @@ If time permits, I intend to eventually add these:
- gesture: long press and drag thumbnails to select multiple items - 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) - gesture: double tap and drag image to zoom in/out (aka quick scale, one finger zoom)
- support: burst groups - support: burst groups
- support: Android R
- subsampling/tiling - subsampling/tiling
## Known Issues ## Known Issues
@ -48,4 +55,5 @@ If time permits, I intend to eventually add these:
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys. Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys.
[Aves logo]: https://github.com/deckerst/aves/blob/master/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png [Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Release%20on%20tag

View file

@ -4,9 +4,30 @@ analyzer:
exclude: exclude:
- lib/generated_plugin_registrant.dart - lib/generated_plugin_registrant.dart
# strong-mode:
# implicit-casts: false
# implicit-dynamic: false
linter: linter:
rules: rules:
- always_declare_return_types # from 'effective dart', excluded
- prefer_const_constructors avoid_function_literals_in_foreach_calls: false # benefit?
- prefer_const_constructors_in_immutables lines_longer_than_80_chars: false # nope
- prefer_const_declarations avoid_classes_with_only_static_members: false # too strict
# from 'effective dart', undecided
prefer_relative_imports: false # check IDE support (auto import, file move)
public_member_api_docs: false # maybe?
# from 'effective dart', included
avoid_types_on_closure_parameters: true
constant_identifier_names: true
prefer_function_declarations_over_variables: true
prefer_interpolation_to_compose_strings: true
unnecessary_brace_in_string_interps: true
unnecessary_lambdas: true
# misc
prefer_const_constructors: false # too noisy
prefer_const_constructors_in_immutables: true
prefer_const_declarations: true

View file

@ -41,7 +41,7 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
compileSdkVersion 29 // latest (or latest-1 if the sources of latest SDK are unavailable) compileSdkVersion 30 // latest (or latest-1 if the sources of latest SDK are unavailable)
lintOptions { lintOptions {
disable 'InvalidPackage' disable 'InvalidPackage'
@ -54,7 +54,7 @@ android {
// but Flutter (as of v1.17.3) fails to run in release mode when using Gradle plugin 4.0: // but Flutter (as of v1.17.3) fails to run in release mode when using Gradle plugin 4.0:
// https://github.com/flutter/flutter/issues/58247 // https://github.com/flutter/flutter/issues/58247
minSdkVersion 24 minSdkVersion 24
targetSdkVersion 29 // same as compileSdkVersion targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']] manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]

View file

@ -36,6 +36,14 @@
--> -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application <application
android:name="io.flutter.app.FlutterApplication" android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View file

@ -100,7 +100,7 @@ public class MainActivity extends FlutterActivity {
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) { if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
if (resultCode != RESULT_OK || data.getData() == null) { if (resultCode != RESULT_OK || data.getData() == null) {
PermissionManager.onPermissionResult(this, requestCode, false, null); PermissionManager.onPermissionResult(requestCode, null);
return; return;
} }
@ -113,7 +113,7 @@ public class MainActivity extends FlutterActivity {
getContentResolver().takePersistableUriPermission(treeUri, takeFlags); getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
// resume pending action // resume pending action
PermissionManager.onPermissionResult(this, requestCode, true, treeUri); PermissionManager.onPermissionResult(requestCode, treeUri);
} }
} }
} }

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.calls; package deckers.thibault.aves.channel.calls;
import android.app.Activity; import android.content.Context;
import android.os.Build; import android.os.Build;
import android.os.storage.StorageManager; import android.os.storage.StorageManager;
import android.os.storage.StorageVolume; import android.os.storage.StorageVolume;
@ -22,10 +22,10 @@ import io.flutter.plugin.common.MethodChannel;
public class StorageHandler implements MethodChannel.MethodCallHandler { public class StorageHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/storage"; public static final String CHANNEL = "deckers.thibault/aves/storage";
private Activity activity; private Context context;
public StorageHandler(Activity activity) { public StorageHandler(Context context) {
this.activity = activity; this.context = context;
} }
@Override @Override
@ -42,12 +42,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
result.success(volumes); result.success(volumes);
break; break;
} }
case "requireVolumeAccessDialog": { case "getInaccessibleDirectories": {
String path = call.argument("path"); List<String> dirPaths = call.argument("dirPaths");
if (path == null) { if (dirPaths == null) {
result.success(true); result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null);
} else { } else {
result.success(PermissionManager.requireVolumeAccessDialog(activity, path)); result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths));
} }
break; break;
} }
@ -60,15 +60,15 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
@RequiresApi(api = Build.VERSION_CODES.N) @RequiresApi(api = Build.VERSION_CODES.N)
private List<Map<String, Object>> getStorageVolumes() { private List<Map<String, Object>> getStorageVolumes() {
List<Map<String, Object>> volumes = new ArrayList<>(); List<Map<String, Object>> volumes = new ArrayList<>();
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(activity)) { for (String volumePath : StorageUtils.getVolumePaths(context)) {
try { try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath)); StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) { if (volume != null) {
Map<String, Object> volumeMap = new HashMap<>(); Map<String, Object> volumeMap = new HashMap<>();
volumeMap.put("path", volumePath); volumeMap.put("path", volumePath);
volumeMap.put("description", volume.getDescription(activity)); volumeMap.put("description", volume.getDescription(context));
volumeMap.put("isPrimary", volume.isPrimary()); volumeMap.put("isPrimary", volume.isPrimary());
volumeMap.put("isRemovable", volume.isRemovable()); volumeMap.put("isRemovable", volume.isRemovable());
volumeMap.put("isEmulated", volume.isEmulated()); volumeMap.put("isEmulated", volume.isEmulated());

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams; package deckers.thibault.aves.channel.streams;
import android.app.Activity; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
@ -25,7 +25,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/imageopstream"; public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
private Activity activity; private Context context;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private Map<String, Object> argMap; private Map<String, Object> argMap;
@ -33,8 +33,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
private String op; private String op;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public ImageOpStreamHandler(Activity activity, Object arguments) { public ImageOpStreamHandler(Context context, Object arguments) {
this.activity = activity; this.context = context;
if (arguments instanceof Map) { if (arguments instanceof Map) {
argMap = (Map<String, Object>) arguments; argMap = (Map<String, Object>) arguments;
this.op = (String) argMap.get("op"); this.op = (String) argMap.get("op");
@ -100,7 +100,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
} }
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> fields) { public void onSuccess(Map<String, Object> fields) {
success(fields); success(fields);
@ -138,7 +138,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
put("uri", uriString); put("uri", uriString);
}}; }};
try { try {
provider.delete(activity, path, uri).get(); provider.delete(context, path, uri).get();
result.put("success", true); result.put("success", true);
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e); Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams; package deckers.thibault.aves.channel.streams;
import android.app.Activity; import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
@ -12,14 +12,14 @@ import io.flutter.plugin.common.EventChannel;
public class MediaStoreStreamHandler implements EventChannel.StreamHandler { public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream"; public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
private Activity activity; private Context context;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private Map<Integer, Integer> knownEntries; private Map<Integer, Integer> knownEntries;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public MediaStoreStreamHandler(Activity activity, Object arguments) { public MediaStoreStreamHandler(Context context, Object arguments) {
this.activity = activity; this.context = context;
if (arguments instanceof Map) { if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments; Map<String, Object> argMap = (Map<String, Object>) arguments;
this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries"); this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries");
@ -47,7 +47,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
} }
void fetchAll() { void fetchAll() {
new MediaStoreImageProvider().fetchAll(activity, knownEntries, this::success); // 350ms new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
endOfStream(); endOfStream();
} }
} }

View file

@ -17,14 +17,14 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
private Activity activity; private Activity activity;
private EventChannel.EventSink eventSink; private EventChannel.EventSink eventSink;
private Handler handler; private Handler handler;
private String volumePath; private String path;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public StorageAccessStreamHandler(Activity activity, Object arguments) { public StorageAccessStreamHandler(Activity activity, Object arguments) {
this.activity = activity; this.activity = activity;
if (arguments instanceof Map) { if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments; Map<String, Object> argMap = (Map<String, Object>) arguments;
this.volumePath = (String) argMap.get("path"); this.path = (String) argMap.get("path");
} }
} }
@ -32,9 +32,9 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
public void onListen(Object o, final EventChannel.EventSink eventSink) { public void onListen(Object o, final EventChannel.EventSink eventSink) {
this.eventSink = eventSink; this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper()); this.handler = new Handler(Looper.getMainLooper());
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath)); Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path`
Runnable onDenied = () -> success(false); Runnable onDenied = () -> success(false); // user cancelled
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied); PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied);
} }
@Override @Override

View file

@ -8,6 +8,7 @@ import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader; import com.drew.imaging.ImageMetadataReader;
@ -53,7 +54,7 @@ public class SourceImageEntry {
public SourceImageEntry() { public SourceImageEntry() {
} }
public SourceImageEntry(Map<String, Object> map) { public SourceImageEntry(@NonNull Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri")); this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path"); this.path = (String) map.get("path");
this.sourceMimeType = (String) map.get("sourceMimeType"); this.sourceMimeType = (String) map.get("sourceMimeType");
@ -121,7 +122,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
public SourceImageEntry fillPreCatalogMetadata(Context context) { public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) {
fillByMediaMetadataRetriever(context); fillByMediaMetadataRetriever(context);
if (hasSize() && (!isVideo() || hasDuration())) return this; if (hasSize() && (!isVideo() || hasDuration())) return this;
fillByMetadataExtractor(context); fillByMetadataExtractor(context);
@ -132,7 +133,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration // finds: width, height, orientation/rotation, date, title, duration
private void fillByMediaMetadataRetriever(Context context) { private void fillByMediaMetadataRetriever(@NonNull Context context) {
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri); MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
try { try {
String width = null, height = null, rotation = null, durationMillis = null; String width = null, height = null, rotation = null, durationMillis = null;
@ -182,7 +183,7 @@ public class SourceImageEntry {
// expects entry with: uri, mimeType // expects entry with: uri, mimeType
// finds: width, height, orientation, date // finds: width, height, orientation, date
private void fillByMetadataExtractor(Context context) { private void fillByMetadataExtractor(@NonNull Context context) {
if (isSvg()) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
@ -244,7 +245,7 @@ public class SourceImageEntry {
// expects entry with: uri // expects entry with: uri
// finds: width, height // finds: width, height
private void fillByBitmapDecode(Context context) { private void fillByBitmapDecode(@NonNull Context context) {
if (isSvg()) return; if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) { try (InputStream is = StorageUtils.openInputStream(context, uri)) {
@ -260,7 +261,7 @@ public class SourceImageEntry {
// convenience method // convenience method
private static Long toLong(Object o) { private static Long toLong(@Nullable Object o) {
if (o == null) return null; if (o == null) return null;
if (o instanceof Integer) return Long.valueOf((Integer) o); if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o; return (long) o;

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -9,8 +8,6 @@ import android.graphics.BitmapFactory;
import android.graphics.Matrix; import android.graphics.Matrix;
import android.media.MediaScannerConnection; import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Log; import android.util.Log;
@ -32,7 +29,6 @@ import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper; import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
@ -52,15 +48,15 @@ public abstract class ImageProvider {
callback.onFailure(new UnsupportedOperationException()); callback.onFailure(new UnsupportedOperationException());
} }
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri uri) { public ListenableFuture<Object> delete(final Context context, final String path, final Uri uri) {
return Futures.immediateFailedFuture(new UnsupportedOperationException()); return Futures.immediateFailedFuture(new UnsupportedOperationException());
} }
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) { public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException()); callback.onFailure(new UnsupportedOperationException());
} }
public void rename(final Activity activity, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) { public void rename(final Context context, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
if (oldPath == null) { if (oldPath == null) {
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri)); callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
return; return;
@ -74,13 +70,7 @@ public abstract class ImageProvider {
return; return;
} }
if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) { DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri);
Runnable runnable = () -> rename(activity, oldPath, oldMediaUri, mimeType, newFilename, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, oldPath, runnable));
return;
}
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri);
try { try {
boolean renamed = df != null && df.renameTo(newFilename); boolean renamed = df != null && df.renameTo(newFilename);
if (!renamed) { if (!renamed) {
@ -92,33 +82,27 @@ public abstract class ImageProvider {
return; return;
} }
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null); MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null);
scanNewPath(activity, newFile.getPath(), mimeType, callback); scanNewPath(context, newFile.getPath(), mimeType, callback);
} }
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
if (PermissionManager.requireVolumeAccessDialog(activity, path)) {
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return;
}
switch (mimeType) { switch (mimeType) {
case MimeTypes.JPEG: case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback); rotateJpeg(context, path, uri, clockwise, callback);
break; break;
case MimeTypes.PNG: case MimeTypes.PNG:
rotatePng(activity, path, uri, clockwise, callback); rotatePng(context, path, uri, clockwise, callback);
break; break;
default: default:
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType)); callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
} }
} }
private void rotateJpeg(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { private void rotateJpeg(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = MimeTypes.JPEG; final String mimeType = MimeTypes.JPEG;
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) { if (originalDocumentFile == null) {
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
return; return;
@ -163,7 +147,7 @@ public abstract class ImageProvider {
Map<String, Object> newFields = new HashMap<>(); Map<String, Object> newFields = new HashMap<>();
newFields.put("orientationDegrees", orientationDegrees); newFields.put("orientationDegrees", orientationDegrees);
// ContentResolver contentResolver = activity.getContentResolver(); // ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues(); // ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first // // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -178,17 +162,17 @@ public abstract class ImageProvider {
// // TODO TLAD catch RecoverableSecurityException // // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null); // int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) { // if (updatedRowCount > 0) {
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
// } else { // } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields); // callback.onSuccess(newFields);
// } // }
} }
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) { private void rotatePng(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = MimeTypes.PNG; final String mimeType = MimeTypes.PNG;
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri); final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) { if (originalDocumentFile == null) {
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri)); callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
return; return;
@ -203,7 +187,7 @@ public abstract class ImageProvider {
Bitmap originalImage; Bitmap originalImage;
try { try {
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri)); originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri));
} catch (FileNotFoundException e) { } catch (FileNotFoundException e) {
callback.onFailure(e); callback.onFailure(e);
return; return;
@ -235,7 +219,7 @@ public abstract class ImageProvider {
newFields.put("width", rotatedWidth); newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight); newFields.put("height", rotatedHeight);
// ContentResolver contentResolver = activity.getContentResolver(); // ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues(); // ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first // // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -250,15 +234,15 @@ public abstract class ImageProvider {
// // TODO TLAD catch RecoverableSecurityException // // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null); // int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) { // if (updatedRowCount > 0) {
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields)); MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
// } else { // } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri); // Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields); // callback.onSuccess(newFields);
// } // }
} }
protected void scanNewPath(final Activity activity, final String path, final String mimeType, final ImageOpCallback callback) { protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri); Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
long contentId = 0; long contentId = 0;
@ -282,7 +266,7 @@ public abstract class ImageProvider {
// we retrieve updated fields as the renamed file became a new entry in the Media Store // we retrieve updated fields as the renamed file became a new entry in the Media Store
String[] projection = {MediaStore.MediaColumns.TITLE}; String[] projection = {MediaStore.MediaColumns.TITLE};
try { try {
Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null); Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) { if (cursor != null) {
if (cursor.moveToNext()) { if (cursor.moveToNext()) {
newFields.put("uri", contentUri.toString()); newFields.put("uri", contentUri.toString());

View file

@ -1,15 +1,12 @@
package deckers.thibault.aves.model.provider; package deckers.thibault.aves.model.provider;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentUris; import android.content.ContentUris;
import android.content.ContentValues; import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.storage.StorageManager; import android.os.storage.StorageManager;
import android.os.storage.StorageVolume; import android.os.storage.StorageVolume;
import android.provider.MediaStore; import android.provider.MediaStore;
@ -35,7 +32,6 @@ import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry; import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry; import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.MimeTypes; import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils; import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils; import deckers.thibault.aves.utils.Utils;
@ -213,26 +209,14 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@Override @Override
public ListenableFuture<Object> delete(final Activity activity, final String path, final Uri mediaUri) { public ListenableFuture<Object> delete(final Context context, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create(); SettableFuture<Object> future = SettableFuture.create();
if (StorageUtils.requireAccessPermission(path)) { if (StorageUtils.requireAccessPermission(path)) {
if (PermissionManager.getVolumeTreeUri(activity, path) == null) {
Runnable runnable = () -> {
try {
future.set(delete(activity, path, mediaUri).get());
} catch (Exception e) {
future.setException(e);
}
};
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, path, runnable));
return future;
}
// if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store // if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission // but it doesn't delete the file, even if the app has the permission
try { try {
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri); DocumentFileCompat df = StorageUtils.getDocumentFile(context, path, mediaUri);
if (df != null && df.delete()) { if (df != null && df.delete()) {
future.set(null); future.set(null);
} else { } else {
@ -245,7 +229,7 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
try { try {
if (activity.getContentResolver().delete(mediaUri, null, null) > 0) { if (context.getContentResolver().delete(mediaUri, null, null) > 0) {
future.set(null); future.set(null);
} else { } else {
future.setException(new Exception("failed to delete row from content provider")); future.setException(new Exception("failed to delete row from content provider"));
@ -257,11 +241,11 @@ public class MediaStoreImageProvider extends ImageProvider {
return future; return future;
} }
private String getVolumeName(final Activity activity, String path) { private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) {
String volumeName = "external"; String volumeName = "external";
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(path)); StorageVolume volume = sm.getStorageVolume(new File(anyPath));
if (volume != null && !volume.isPrimary()) { if (volume != null && !volume.isPrimary()) {
String uuid = volume.getUuid(); String uuid = volume.getUuid();
if (uuid != null) { if (uuid != null) {
@ -275,20 +259,14 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
@Override @Override
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) { public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) { DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir);
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
return;
}
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir);
if (destinationDirDocFile == null) { if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
return; return;
} }
MediaStoreMoveDestination destination = new MediaStoreMoveDestination(activity, destinationDir); MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir);
if (destination.volumePath == null) { if (destination.volumePath == null) {
callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir)); callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir));
return; return;
@ -303,14 +281,14 @@ public class MediaStoreImageProvider extends ImageProvider {
put("uri", sourceUri.toString()); put("uri", sourceUri.toString());
}}; }};
// TODO TLAD check if there is any downside to use tree document files with scoped storage on API 30+ // on API 30 we cannot get access granted directly to a volume root from its document tree,
// when testing scoped storage on API 29, it seems less constraining to use tree document files than to rely on the Media Store // but it is still less constraining to use tree document files than to rely on the Media Store
try { try {
ListenableFuture<Map<String, Object>> newFieldsFuture; ListenableFuture<Map<String, Object>> newFieldsFuture;
// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { // if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
// newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destination, mimeType, copy); // newFieldsFuture = moveSingleByMediaStoreInsert(context, sourcePath, sourceUri, destination, mimeType, copy);
// } else { // } else {
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
// } // }
Map<String, Object> newFields = newFieldsFuture.get(); Map<String, Object> newFields = newFieldsFuture.get();
result.put("success", true); result.put("success", true);
@ -330,7 +308,7 @@ public class MediaStoreImageProvider extends ImageProvider {
// - there is no documentation regarding support for usage with removable storage // - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
@RequiresApi(api = Build.VERSION_CODES.Q) @RequiresApi(api = Build.VERSION_CODES.Q)
private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri,
final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create(); SettableFuture<Map<String, Object>> future = SettableFuture.create();
@ -344,22 +322,23 @@ public class MediaStoreImageProvider extends ImageProvider {
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ?
MediaStore.Video.Media.getContentUri(destination.volumeName) : MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(destination.volumeName); MediaStore.Images.Media.getContentUri(volumeName);
Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);
if (destinationUri == null) { if (destinationUri == null) {
future.setException(new Exception("failed to insert row to content resolver")); future.setException(new Exception("failed to insert row to content resolver"));
} else { } else {
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri); DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri);
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri); DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri);
sourceFile.copyTo(destinationFile); sourceFile.copyTo(destinationFile);
boolean deletedSource = false; boolean deletedSource = false;
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourcePath, sourceUri).get(); delete(context, sourcePath, sourceUri).get();
deletedSource = true; deletedSource = true;
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
@ -384,7 +363,7 @@ public class MediaStoreImageProvider extends ImageProvider {
// We can create an item via `DocumentFile.createFile()`, but: // We can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI // - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name // - the underlying document provider controls the new file name
private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create(); SettableFuture<Map<String, Object>> future = SettableFuture.create();
try { try {
@ -396,12 +375,12 @@ public class MediaStoreImageProvider extends ImageProvider {
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension); DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension);
DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.getUri()); DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.getUri());
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri); DocumentFileCompat source = DocumentFileCompat.fromSingleUri(context, sourceUri);
source.copyTo(destinationDocFile); source.copyTo(destinationDocFile);
// the source file name and the created document file name can be different when: // the source file name and the created document file name can be different when:
@ -414,7 +393,7 @@ public class MediaStoreImageProvider extends ImageProvider {
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourcePath, sourceUri).get(); delete(context, sourcePath, sourceUri).get();
deletedSource = true; deletedSource = true;
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e); Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
@ -422,7 +401,7 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
boolean finalDeletedSource = deletedSource; boolean finalDeletedSource = deletedSource;
scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override @Override
public void onSuccess(Map<String, Object> newFields) { public void onSuccess(Map<String, Object> newFields) {
newFields.put("deletedSource", finalDeletedSource); newFields.put("deletedSource", finalDeletedSource);
@ -451,15 +430,15 @@ public class MediaStoreImageProvider extends ImageProvider {
} }
class MediaStoreMoveDestination { class MediaStoreMoveDestination {
final String volumeName; final String volumeNameForMediaStore;
final String volumePath; final String volumePath;
final String relativePath; final String relativePath;
final String fullPath; final String fullPath;
MediaStoreMoveDestination(Activity activity, String destinationDir) { MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir; fullPath = destinationDir;
volumeName = getVolumeName(activity, destinationDir); volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
volumePath = StorageUtils.getVolumePath(activity, destinationDir).orElse(null); volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
} }
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import android.app.Activity; import android.app.Activity;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.UriPermission; import android.content.UriPermission;
import android.net.Uri; import android.net.Uri;
@ -11,12 +12,19 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import com.google.common.base.Splitter;
import java.io.File; import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
public class PermissionManager { public class PermissionManager {
@ -27,40 +35,15 @@ public class PermissionManager {
// permission request code to pending runnable // permission request code to pending runnable
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>(); private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) { public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null; Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + path);
} pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(path, onGranted, onDenied));
// check access permission to volume root directory & return its tree URI if available
@Nullable
public static Uri getVolumeTreeUri(Activity activity, @NonNull String anyPath) {
String volumeTreeUri = StorageUtils.getVolumeTreeUriForPath(activity, anyPath).orElse(null);
Optional<UriPermission> uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream()
.filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri))
.findFirst();
return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public static void showVolumeAccessDialog(final Activity activity, @NonNull String anyPath, final Runnable pendingRunnable) {
String volumePath = StorageUtils.getVolumePath(activity, anyPath).orElse(null);
// TODO TLAD show volume name/ID in the message
new AlertDialog.Builder(activity)
.setTitle("Storage Volume Access")
.setMessage("Please select the root directory of the storage volume in the next screen, so that this app has permission to access it and complete your request.")
.setPositiveButton(android.R.string.ok, (dialog, button) -> requestVolumeAccess(activity, volumePath, pendingRunnable, null))
.show();
}
public static void requestVolumeAccess(Activity activity, String volumePath, Runnable onGranted, Runnable onDenied) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=" + volumePath);
pendingPermissionMap.put(VOLUME_ROOT_PERMISSION_REQUEST_CODE, new PendingPermissionHandler(volumePath, onGranted, onDenied));
Intent intent = null; Intent intent = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && volumePath != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
StorageManager sm = activity.getSystemService(StorageManager.class); StorageManager sm = activity.getSystemService(StorageManager.class);
if (sm != null) { if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(volumePath)); StorageVolume volume = sm.getStorageVolume(new File(path));
if (volume != null) { if (volume != null) {
intent = volume.createOpenDocumentTreeIntent(); intent = volume.createOpenDocumentTreeIntent();
} }
@ -75,25 +58,109 @@ public class PermissionManager {
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null); ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null);
} }
public static void onPermissionResult(Activity activity, int requestCode, boolean granted, Uri treeUri) { public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) {
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", granted=" + granted); Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri);
boolean granted = treeUri != null;
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode); PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
if (handler == null) return; if (handler == null) return;
StorageUtils.setVolumeTreeUri(activity, handler.volumePath, treeUri.toString());
Runnable runnable = granted ? handler.onGranted : handler.onDenied; Runnable runnable = granted ? handler.onGranted : handler.onDenied;
if (runnable == null) return; if (runnable == null) return;
runnable.run(); runnable.run();
} }
static class PendingPermissionHandler { public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
String volumePath; return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst();
Runnable onGranted; }
Runnable onDenied;
PendingPermissionHandler(String volumePath, Runnable onGranted, Runnable onDenied) { public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
this.volumePath = volumePath; Set<String> grantedDirs = getGrantedDirs(context);
// find set of inaccessible directories for each volume
Map<String, Set<String>> dirsPerVolume = new HashMap<>();
for (String dirPath : dirPaths) {
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
if (grantedDirs.stream().noneMatch(dirPath::startsWith)) {
// inaccessible dirs
StorageUtils.PathSegments segments = new StorageUtils.PathSegments(context, dirPath);
Set<String> dirSet = dirsPerVolume.getOrDefault(segments.volumePath, new HashSet<>());
if (dirSet != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R
String relativeDir = segments.relativeDir;
if (relativeDir != null) {
Iterator<String> iterator = Splitter.on(File.separatorChar).omitEmptyStrings().split(relativeDir).iterator();
if (iterator.hasNext()) {
// primary dir
dirSet.add(iterator.next());
}
}
} else {
// request volume root until Android Q
dirSet.add("");
}
}
dirsPerVolume.put(segments.volumePath, dirSet);
}
}
// format for easier handling on Flutter
List<Map<String, String>> inaccessibleDirs = new ArrayList<>();
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (Map.Entry<String, Set<String>> volumeEntry : dirsPerVolume.entrySet()) {
String volumePath = volumeEntry.getKey();
String volumeDescription = "";
try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) {
volumeDescription = volume.getDescription(context);
}
} catch (IllegalArgumentException e) {
// ignore
}
for (String relativeDir : volumeEntry.getValue()) {
HashMap<String, String> dirMap = new HashMap<>();
dirMap.put("volumePath", volumePath);
dirMap.put("volumeDescription", volumeDescription);
dirMap.put("relativeDir", relativeDir);
inaccessibleDirs.add(dirMap);
}
}
}
Log.d(LOG_TAG, "getInaccessibleDirectories dirPaths=" + dirPaths + " -> inaccessibleDirs=" + inaccessibleDirs);
return inaccessibleDirs;
}
private static Set<String> getGrantedDirs(Context context) {
HashSet<String> accessibleDirs = new HashSet<>();
// find paths matching URIs granted by the user
for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) {
Optional<String> dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.getUri());
dirPath.ifPresent(accessibleDirs::add);
}
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
String primaryPath = StorageUtils.getPrimaryVolumePath();
accessibleDirs.add(primaryPath);
}
Log.d(LOG_TAG, "getGrantedDirs accessibleDirs=" + accessibleDirs);
return accessibleDirs;
}
static class PendingPermissionHandler {
final String path;
final Runnable onGranted; // user gave access to a directory, with no guarantee that it matches the specified `path`
final Runnable onDenied; // user cancelled
PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
this.path = path;
this.onGranted = onGranted; this.onGranted = onGranted;
this.onDenied = onDenied; this.onDenied = onDenied;
} }

View file

@ -1,14 +1,15 @@
package deckers.thibault.aves.utils; package deckers.thibault.aves.utils;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Environment; import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -21,9 +22,6 @@ import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.base.Splitter; import com.google.common.base.Splitter;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
@ -31,12 +29,13 @@ import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.Map; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StorageUtils { public class StorageUtils {
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class); private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
@ -51,35 +50,37 @@ public class StorageUtils {
// primary volume path, with trailing "/" // primary volume path, with trailing "/"
private static String mPrimaryVolumePath; private static String mPrimaryVolumePath;
private static String getPrimaryVolumePath() { public static String getPrimaryVolumePath() {
if (mPrimaryVolumePath == null) { if (mPrimaryVolumePath == null) {
mPrimaryVolumePath = findPrimaryVolumePath(); mPrimaryVolumePath = findPrimaryVolumePath();
} }
return mPrimaryVolumePath; return mPrimaryVolumePath;
} }
public static String[] getVolumePaths(Context context) { public static String[] getVolumePaths(@NonNull Context context) {
if (mStorageVolumePaths == null) { if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context); mStorageVolumePaths = findVolumePaths(context);
} }
return mStorageVolumePaths; return mStorageVolumePaths;
} }
public static Optional<String> getVolumePath(Context context, @NonNull String anyPath) { public static Optional<String> getVolumePath(@NonNull Context context, @NonNull String anyPath) {
return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst();
} }
@Nullable @Nullable
private static Iterator<String> getPathStepIterator(Context context, @NonNull String anyPath) { private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
Optional<String> volumePathOpt = getVolumePath(context, anyPath); if (root == null) {
if (!volumePathOpt.isPresent()) return null; root = getVolumePath(context, anyPath).orElse(null);
if (root == null) return null;
}
String relativePath = null, filename = null; String relativePath = null, filename = null;
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1; int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
int volumePathLength = volumePathOpt.get().length(); int rootLength = root.length();
if (lastSeparatorIndex > volumePathLength) { if (lastSeparatorIndex > rootLength) {
filename = anyPath.substring(lastSeparatorIndex); filename = anyPath.substring(lastSeparatorIndex);
relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex); relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
} }
if (relativePath == null) return null; if (relativePath == null) return null;
@ -119,7 +120,21 @@ public class StorageUtils {
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) { if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
// fix of empty raw emulated storage on marshmallow // fix of empty raw emulated storage on marshmallow
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
File[] files = context.getExternalFilesDirs(null); List<File> files;
boolean validFiles;
do {
// `getExternalFilesDirs` sometimes include `null` when called right after getting read access
// (e.g. on API 30 emulator) so we retry until the file system is ready
files = Arrays.asList(context.getExternalFilesDirs(null));
validFiles = !files.contains(null);
if (!validFiles) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Log.e(LOG_TAG, "insomnia", e);
}
}
} while (!validFiles);
for (File file : files) { for (File file : files) {
String applicationSpecificAbsolutePath = file.getAbsolutePath(); String applicationSpecificAbsolutePath = file.getAbsolutePath();
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data")); String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
@ -210,43 +225,86 @@ public class StorageUtils {
* Volume tree URIs * Volume tree URIs
*/ */
// serialized map from storage volume paths to their document tree URIs, from the Documents Provider private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
// e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A" StorageManager sm = context.getSystemService(StorageManager.class);
private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris"; if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) { if (volume != null) {
Map<String, String> map = getVolumeTreeUris(activity); if (volume.isPrimary()) {
map.put(volumePath, treeUri); return Optional.of("primary");
}
SharedPreferences.Editor editor = activity.getPreferences(Context.MODE_PRIVATE).edit(); String uuid = volume.getUuid();
String json = new JSONObject(map).toString(); if (uuid != null) {
editor.putString(PREF_VOLUME_TREE_URIS, json); return Optional.of(uuid.toUpperCase());
editor.apply();
}
private static Map<String, String> getVolumeTreeUris(Activity activity) {
Map<String, String> map = new HashMap<>();
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
String json = preferences.getString(PREF_VOLUME_TREE_URIS, new JSONObject().toString());
if (json != null) {
try {
JSONObject jsonObject = new JSONObject(json);
Iterator<String> iterator = jsonObject.keys();
while (iterator.hasNext()) {
String k = iterator.next();
String v = (String) jsonObject.get(k);
map.put(k, v);
} }
} catch (JSONException e) {
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e);
} }
} }
return map; Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
return Optional.empty();
} }
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) { private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath)); if (uuid.equals("primary")) {
return Optional.of(getPrimaryVolumePath());
}
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(context)) {
try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
return Optional.of(volumePath);
}
} catch (IllegalArgumentException e) {
// ignore
}
}
}
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
return Optional.empty();
}
// e.g.
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
static Optional<Uri> convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) {
Optional<String> uuid = getVolumeUuidForTreeUri(context, dirPath);
if (uuid.isPresent()) {
String relativeDir = new PathSegments(context, dirPath).relativeDir;
if (relativeDir == null) {
relativeDir = "";
} else if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length() - 1);
}
Uri treeUri = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", uuid.get() + ":" + relativeDir);
return Optional.of(treeUri);
}
Log.e(LOG_TAG, "failed to convert dirPath=" + dirPath + " to tree URI");
return Optional.empty();
}
// e.g.
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
static Optional<String> convertTreeUriToDirPath(@NonNull Context context, @NonNull Uri treeUri) {
String encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length());
Matcher matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded));
if (matcher.find()) {
String uuid = matcher.group(1);
String relativePath = matcher.group(2);
if (uuid != null && relativePath != null) {
Optional<String> volumePath = getVolumePathFromTreeUriUuid(context, uuid);
if (volumePath.isPresent()) {
String dirPath = volumePath.get() + relativePath;
if (!dirPath.endsWith(File.separator)) {
dirPath += File.separator;
}
return Optional.of(dirPath);
}
}
}
Log.e(LOG_TAG, "failed to convert treeUri=" + treeUri + " to path");
return Optional.empty();
} }
/** /**
@ -254,20 +312,22 @@ public class StorageUtils {
*/ */
@Nullable @Nullable
public static DocumentFileCompat getDocumentFile(@NonNull Activity activity, @NonNull String anyPath, @NonNull Uri mediaUri) { public static DocumentFileCompat getDocumentFile(@NonNull Context context, @NonNull String anyPath, @NonNull Uri mediaUri) {
if (requireAccessPermission(anyPath)) { if (requireAccessPermission(anyPath)) {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream // need a document URI (not a media content URI) to open a `DocumentFile` output stream
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// cleanest API to get it // cleanest API to get it
Uri docUri = MediaStore.getDocumentUri(activity, mediaUri); Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
if (docUri != null) { if (docUri != null) {
return DocumentFileCompat.fromSingleUri(activity, docUri); return DocumentFileCompat.fromSingleUri(context, docUri);
} }
} }
// fallback for older APIs // fallback for older APIs
Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath); return getVolumePath(context, anyPath)
Optional<DocumentFileCompat> docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath); .flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
return docFile.orElse(null); .flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
.orElse(null);
} }
// good old `File` // good old `File`
return DocumentFileCompat.fromFile(new File(anyPath)); return DocumentFileCompat.fromFile(new File(anyPath));
@ -275,16 +335,21 @@ public class StorageUtils {
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
// returns null if directory does not exist and could not be created // returns null if directory does not exist and could not be created
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) { public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
if (requireAccessPermission(directoryPath)) { if (!dirPath.endsWith(File.separator)) {
Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath); dirPath += File.separator;
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri); }
if (requireAccessPermission(dirPath)) {
String grantedDir = PermissionManager.getGrantedDirForPath(context, dirPath).orElse(null);
if (grantedDir == null) return null;
Uri rootTreeUri = convertDirPathToTreeUri(context, grantedDir).orElse(null);
if (rootTreeUri == null) return null;
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (parentFile == null) return null; if (parentFile == null) return null;
if (!directoryPath.endsWith(File.separator)) { Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
directoryPath += File.separator;
}
Iterator<String> pathIterator = getPathStepIterator(activity, directoryPath);
while (pathIterator != null && pathIterator.hasNext()) { while (pathIterator != null && pathIterator.hasNext()) {
String dirName = pathIterator.next(); String dirName = pathIterator.next();
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName); DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
@ -304,10 +369,10 @@ public class StorageUtils {
} }
return parentFile; return parentFile;
} else { } else {
File directory = new File(directoryPath); File directory = new File(dirPath);
if (!directory.exists()) { if (!directory.exists()) {
if (!directory.mkdirs()) { if (!directory.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=" + directoryPath); Log.e(LOG_TAG, "failed to create directories at path=" + dirPath);
return null; return null;
} }
} }
@ -323,23 +388,19 @@ public class StorageUtils {
temp.deleteOnExit(); temp.deleteOnExit();
return temp.getPath(); return temp.getPath();
} catch (IOException e) { } catch (IOException e) {
Log.w(LOG_TAG, "failed to copy file from path=" + path); Log.e(LOG_TAG, "failed to copy file from path=" + path);
} }
return null; return null;
} }
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) { private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
if (rootTreeUri == null || path == null) {
return Optional.empty();
}
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri); DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
if (documentFile == null) { if (documentFile == null) {
return Optional.empty(); return Optional.empty();
} }
// follow the entry path down the document tree // follow the entry path down the document tree
Iterator<String> pathIterator = getPathStepIterator(context, path); Iterator<String> pathIterator = getPathStepIterator(context, anyPath, null);
while (pathIterator != null && pathIterator.hasNext()) { while (pathIterator != null && pathIterator.hasNext()) {
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()); documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
if (documentFile == null) { if (documentFile == null) {
@ -378,7 +439,7 @@ public class StorageUtils {
return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost()); return uri != null && ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme()) && MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost());
} }
public static InputStream openInputStream(Context context, Uri uri) throws FileNotFoundException { public static InputStream openInputStream(@NonNull Context context, @NonNull Uri uri) throws FileNotFoundException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// we get a permission denial if we require original from a provider other than the media store // we get a permission denial if we require original from a provider other than the media store
if (isMediaStoreContentUri(uri)) { if (isMediaStoreContentUri(uri)) {
@ -388,7 +449,7 @@ public class StorageUtils {
return context.getContentResolver().openInputStream(uri); return context.getContentResolver().openInputStream(uri);
} }
public static MediaMetadataRetriever openMetadataRetriever(Context context, Uri uri) { public static MediaMetadataRetriever openMetadataRetriever(@NonNull Context context, @NonNull Uri uri) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever(); MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -399,8 +460,28 @@ public class StorageUtils {
} }
retriever.setDataSource(context, uri); retriever.setDataSource(context, uri);
} catch (Exception e) { } catch (Exception e) {
Log.w(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e); Log.e(LOG_TAG, "failed to open MediaMetadataRetriever for uri=" + uri, e);
} }
return retriever; return retriever;
} }
public static class PathSegments {
String fullPath; // should match "volumePath + relativeDir + filename"
String volumePath; // with trailing "/"
String relativeDir; // with trailing "/"
String filename; // null for directories
PathSegments(@NonNull Context context, @NonNull String fullPath) {
this.fullPath = fullPath;
volumePath = StorageUtils.getVolumePath(context, fullPath).orElse(null);
if (volumePath == null) return;
int lastSeparatorIndex = fullPath.lastIndexOf(File.separator) + 1;
int volumePathLength = volumePath.length();
if (lastSeparatorIndex > volumePathLength) {
filename = fullPath.substring(lastSeparatorIndex);
relativeDir = fullPath.substring(volumePathLength, lastSeparatorIndex);
}
}
}
} }

View file

@ -45,10 +45,10 @@ class _AvesAppState extends State<AvesApp> {
scaffoldBackgroundColor: Colors.grey[900], scaffoldBackgroundColor: Colors.grey[900],
buttonColor: accentColor, buttonColor: accentColor,
toggleableActiveColor: accentColor, toggleableActiveColor: accentColor,
tooltipTheme: const TooltipThemeData( tooltipTheme: TooltipThemeData(
verticalOffset: 32, verticalOffset: 32,
), ),
appBarTheme: const AppBarTheme( appBarTheme: AppBarTheme(
textTheme: TextTheme( textTheme: TextTheme(
headline6: TextStyle( headline6: TextStyle(
fontSize: 20, fontSize: 20,
@ -58,12 +58,12 @@ class _AvesAppState extends State<AvesApp> {
), ),
), ),
), ),
home: FutureBuilder( home: FutureBuilder<void>(
future: _appSetup, future: _appSetup,
builder: (context, AsyncSnapshot<void> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return const Icon(AIcons.error); if (snapshot.hasError) return Icon(AIcons.error);
if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); if (snapshot.connectionState != ConnectionState.done) return Scaffold();
return settings.hasAcceptedTerms ? const HomePage() : const WelcomePage(); return settings.hasAcceptedTerms ? HomePage() : WelcomePage();
}, },
), ),
); );

View file

@ -34,7 +34,7 @@ class MimeFilter extends CollectionFilter {
_label ??= lowMime.split('/')[0].toUpperCase(); _label ??= lowMime.split('/')[0].toUpperCase();
} else { } else {
_filter = (entry) => entry.mimeType == lowMime; _filter = (entry) => entry.mimeType == lowMime;
if (lowMime == MimeTypes.SVG) { if (lowMime == MimeTypes.svg) {
_label = 'SVG'; _label = 'SVG';
} }
_label ??= lowMime.split('/')[1].toUpperCase(); _label ??= lowMime.split('/')[1].toUpperCase();

View file

@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart';
class QueryFilter extends CollectionFilter { class QueryFilter extends CollectionFilter {
static const type = 'query'; static const type = 'query';
static final exactRegex = RegExp('^"(.*)"\$'); static final RegExp exactRegex = RegExp('^"(.*)"\$');
final String query; final String query;
final bool colorful; final bool colorful;
@ -39,7 +39,7 @@ class QueryFilter extends CollectionFilter {
bool get isUnique => false; bool get isUnique => false;
@override @override
String get label => '${query}'; String get label => '$query';
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size);

View file

@ -152,10 +152,10 @@ class ImageEntry {
bool get isFavourite => favourites.isFavourite(this); bool get isFavourite => favourites.isFavourite(this);
bool get isSvg => mimeType == MimeTypes.SVG; 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) // 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);
bool get isVideo => mimeType.startsWith('video'); bool get isVideo => mimeType.startsWith('video');
@ -167,7 +167,7 @@ class ImageEntry {
bool get canPrint => !isVideo; bool get canPrint => !isVideo;
bool get canRotate => canEdit && (mimeType == MimeTypes.JPEG || mimeType == MimeTypes.PNG); bool get canRotate => canEdit && (mimeType == MimeTypes.jpeg || mimeType == MimeTypes.png);
bool get rotated => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90; bool get rotated => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
@ -270,7 +270,7 @@ class ImageEntry {
final coordinates = Coordinates(latitude, longitude); final coordinates = Coordinates(latitude, longitude);
try { try {
final call = () => Geocoder.local.findAddressesFromCoordinates(coordinates); Future<List<Address>> call() => Geocoder.local.findAddressesFromCoordinates(coordinates);
final addresses = await (background final addresses = await (background
? servicePolicy.call( ? servicePolicy.call(
call, call,

View file

@ -179,11 +179,12 @@ class AddressDetails {
} }
} }
@immutable
class FavouriteRow { class FavouriteRow {
final int contentId; final int contentId;
final String path; final String path;
FavouriteRow({ const FavouriteRow({
this.contentId, this.contentId,
this.path, this.path,
}); });

View file

@ -1,15 +1,15 @@
class MimeTypes { class MimeTypes {
static const String ANY_IMAGE = 'image/*'; static const String anyImage = 'image/*';
static const String GIF = 'image/gif'; static const String gif = 'image/gif';
static const String HEIC = 'image/heic'; static const String heic = 'image/heic';
static const String HEIF = 'image/heif'; static const String heif = 'image/heif';
static const String JPEG = 'image/jpeg'; static const String jpeg = 'image/jpeg';
static const String PNG = 'image/png'; static const String png = 'image/png';
static const String SVG = 'image/svg+xml'; static const String svg = 'image/svg+xml';
static const String WEBP = 'image/webp'; static const String webp = 'image/webp';
static const String ANY_VIDEO = 'video/*'; static const String anyVideo = 'video/*';
static const String AVI = 'video/avi'; static const String avi = 'video/avi';
static const String MP2T = 'video/mp2t'; // .m2ts static const String mp2t = 'video/mp2t'; // .m2ts
static const String MP4 = 'video/mp4'; static const String mp4 = 'video/mp4';
} }

View file

@ -76,6 +76,7 @@ class Settings {
// convenience methods // convenience methods
// ignore: avoid_positional_boolean_parameters
bool getBoolOrDefault(String key, bool defaultValue) => _prefs.getKeys().contains(key) ? _prefs.getBool(key) : defaultValue; bool getBoolOrDefault(String key, bool defaultValue) => _prefs.getKeys().contains(key) ? _prefs.getBool(key) : defaultValue;
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) { T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {

View file

@ -168,8 +168,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
]); ]);
break; break;
case SortFactor.name: case SortFactor.name:
final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory); final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
final compare = (a, b) { int compare(a, b) {
final ua = source.getUniqueAlbumName(a); final ua = source.getUniqueAlbumName(a);
final ub = source.getUniqueAlbumName(b); final ub = source.getUniqueAlbumName(b);
final c = compareAsciiUpperCase(ua, ub); final c = compareAsciiUpperCase(ua, ub);
@ -177,7 +177,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
final va = androidFileUtils.getStorageVolume(a)?.path ?? ''; final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
final vb = androidFileUtils.getStorageVolume(b)?.path ?? ''; final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
return compareAsciiUpperCase(va, vb); return compareAsciiUpperCase(va, vb);
}; }
sections = SplayTreeMap.of(byAlbum, compare); sections = SplayTreeMap.of(byAlbum, compare);
break; break;
} }

View file

@ -63,7 +63,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
_rawEntries.addAll(entries); _rawEntries.addAll(entries);
addFolderPath(_rawEntries.map((entry) => entry.directory)); addFolderPath(_rawEntries.map((entry) => entry.directory));
invalidateFilterEntryCounts(); invalidateFilterEntryCounts();
eventBus.fire(const EntryAddedEvent()); eventBus.fire(EntryAddedEvent());
} }
void removeEntries(Iterable<ImageEntry> entries) { void removeEntries(Iterable<ImageEntry> entries) {

View file

@ -57,6 +57,7 @@ mixin LocationMixin on SourceBase {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= _commitCountThreshold) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses)); await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
newAddresses.clear(); newAddresses.clear();
} }
} }
@ -74,7 +75,7 @@ mixin LocationMixin on SourceBase {
void updateLocations() { void updateLocations() {
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList(); final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
final lister = (String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); List<String> lister(String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
sortedCountries = lister((address) => '${address.countryName};${address.countryCode}'); sortedCountries = lister((address) => '${address.countryName};${address.countryCode}');
sortedPlaces = lister((address) => address.place); sortedPlaces = lister((address) => address.place);

View file

@ -23,7 +23,7 @@ mixin TagMixin on SourceBase {
Future<void> catalogEntries() async { Future<void> catalogEntries() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final todo = rawEntries.where((entry) => !entry.isCatalogued).toList(); final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList();
if (todo.isEmpty) return; if (todo.isEmpty) return;
var progressDone = 0; var progressDone = 0;
@ -37,6 +37,7 @@ mixin TagMixin on SourceBase {
newMetadata.add(entry.catalogMetadata); newMetadata.add(entry.catalogMetadata);
if (newMetadata.length >= _commitCountThreshold) { if (newMetadata.length >= _commitCountThreshold) {
await metadataDb.saveMetadata(List.unmodifiable(newMetadata)); await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
onCatalogMetadataChanged();
newMetadata.clear(); newMetadata.clear();
} }
} }

View file

@ -88,7 +88,7 @@ class AndroidAppService {
} }
} }
static Future<void> share(Set<ImageEntry> entries) async { static Future<void> share(Iterable<ImageEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps // loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));

View file

@ -18,16 +18,18 @@ class AndroidFileService {
return []; return [];
} }
static Future<bool> requireVolumeAccessDialog(String path) async { // returns a list of directories,
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try { try {
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{ final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'path': path, 'dirPaths': dirPaths.toList(),
}); });
return result as bool; return (result as List).cast<Map>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('requireVolumeAccessDialog failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return false; return null;
} }
// returns whether user granted access to volume root at `volumePath` // returns whether user granted access to volume root at `volumePath`

View file

@ -140,7 +140,7 @@ class ImageFileService {
try { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', 'op': 'delete',
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event)); }).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
@ -153,7 +153,7 @@ class ImageFileService {
try { try {
return opChannel.receiveBroadcastStream(<String, dynamic>{ return opChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move', 'op': 'move',
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy, 'copy': copy,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
}).map((event) => MoveOpEvent.fromMap(event)); }).map((event) => MoveOpEvent.fromMap(event));
@ -192,11 +192,12 @@ class ImageFileService {
} }
} }
@immutable
class ImageOpEvent { class ImageOpEvent {
final bool success; final bool success;
final String uri; final String uri;
ImageOpEvent({ const ImageOpEvent({
this.success, this.success,
this.uri, this.uri,
}); });
@ -226,7 +227,7 @@ class ImageOpEvent {
class MoveOpEvent extends ImageOpEvent { class MoveOpEvent extends ImageOpEvent {
final Map newFields; final Map newFields;
MoveOpEvent({bool success, String uri, this.newFields}) const MoveOpEvent({bool success, String uri, this.newFields})
: super( : super(
success: success, success: success,
uri: uri, uri: uri,

View file

@ -26,7 +26,7 @@ class MetadataService {
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry, {bool background = false}) async { static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry, {bool background = false}) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
final call = () async { Future<CatalogMetadata> call() async {
try { try {
// return map with: // return map with:
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
@ -47,7 +47,7 @@ class MetadataService {
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return null; return null;
}; }
return background return background
? servicePolicy.call( ? servicePolicy.call(
call, call,

View file

@ -5,7 +5,7 @@ final Map<String, Color> _stringColors = {};
Color stringToColor(String string, {double saturation = .8, double lightness = .6}) { Color stringToColor(String string, {double saturation = .8, double lightness = .6}) {
var color = _stringColors[string]; var color = _stringColors[string];
if (color == null) { if (color == null) {
final hash = string.codeUnits.fold(0, (prev, el) => prev = el + ((prev << 5) - prev)); final hash = string.codeUnits.fold<int>(0, (prev, el) => prev = el + ((prev << 5) - prev));
final hue = (hash % 360).toDouble(); final hue = (hash % 360).toDouble();
color = HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor(); color = HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor();
_stringColors[string] = color; _stringColors[string] = color;

View file

@ -30,5 +30,5 @@ class Durations {
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static var staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
} }

View file

@ -20,7 +20,7 @@ extension ExtraDateTime on DateTime {
bool get isToday => isAtSameDayAs(DateTime.now()); bool get isToday => isAtSameDayAs(DateTime.now());
bool get isYesterday => isAtSameDayAs(DateTime.now().subtract(const Duration(days: 1))); bool get isYesterday => isAtSameDayAs(DateTime.now().subtract(Duration(days: 1)));
bool get isThisMonth => isAtSameMonthAs(DateTime.now()); bool get isThisMonth => isAtSameMonthAs(DateTime.now());

View file

@ -11,20 +11,20 @@ class AboutPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('About'), title: Text('About'),
), ),
body: SafeArea( body: SafeArea(
child: AnimationLimiter( child: AnimationLimiter(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverPadding( SliverPadding(
padding: const EdgeInsets.only(top: 16), padding: EdgeInsets.only(top: 16),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate(
[ [
AppReference(), AppReference(),
const SizedBox(height: 16), SizedBox(height: 16),
const Divider(), Divider(),
], ],
), ),
), ),
@ -92,7 +92,7 @@ class _AppReferenceState extends State<AppReference> {
children: [ children: [
WidgetSpan( WidgetSpan(
child: Padding( child: Padding(
padding: const EdgeInsetsDirectional.only(end: 4), padding: EdgeInsetsDirectional.only(end: 4),
child: FlutterLogo( child: FlutterLogo(
size: style.fontSize * 1.25, size: style.fontSize * 1.25,
), ),

View file

@ -39,7 +39,7 @@ class _LicensesState extends State<Licenses> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
@ -69,7 +69,7 @@ class _LicensesState extends State<Licenses> {
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsetsDirectional.only(start: 8), padding: EdgeInsetsDirectional.only(start: 8),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@ -95,13 +95,13 @@ class _LicensesState extends State<Licenses> {
setState(() {}); setState(() {});
}, },
tooltip: 'Sort', tooltip: 'Sort',
icon: const Icon(AIcons.sort), icon: Icon(AIcons.sort),
), ),
], ],
), ),
), ),
const SizedBox(height: 8), SizedBox(height: 8),
const Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('The following sets forth attribution notices for third party software that may be contained in this application.'), child: Text('The following sets forth attribution notices for third party software that may be contained in this application.'),
), ),
@ -122,17 +122,17 @@ class LicenseRow extends StatelessWidget {
final subColor = bodyTextStyle.color.withOpacity(.6); final subColor = bodyTextStyle.color.withOpacity(.6);
return Padding( return Padding(
padding: const EdgeInsets.only(top: 16), padding: EdgeInsets.only(top: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
LinkChip( LinkChip(
text: package.name, text: package.name,
url: package.sourceUrl, url: package.sourceUrl,
textStyle: const TextStyle(fontWeight: FontWeight.bold), textStyle: TextStyle(fontWeight: FontWeight.bold),
), ),
Padding( Padding(
padding: const EdgeInsetsDirectional.only(start: 16), padding: EdgeInsetsDirectional.only(start: 16),
child: LinkChip( child: LinkChip(
text: package.license, text: package.license,
url: package.licenseUrl, url: package.licenseUrl,

View file

@ -144,7 +144,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
// so that we can also detect taps around the title `Text` // so that we can also detect taps around the title `Text`
child: Container( child: Container(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing), padding: EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing),
color: Colors.transparent, color: Colors.transparent,
height: kToolbarHeight, height: kToolbarHeight,
child: title, child: title,
@ -155,7 +155,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
animation: collection.selectionChangeNotifier, animation: collection.selectionChangeNotifier,
builder: (context, child) { builder: (context, child) {
final count = collection.selection.length; final count = collection.selection.length;
return Text(Intl.plural(count, zero: 'Select items', one: '${count} item', other: '${count} items')); return Text(Intl.plural(count, zero: 'Select items', one: '$count item', other: '$count items'));
}, },
); );
} }
@ -166,7 +166,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return [ return [
if (collection.isBrowsing) if (collection.isBrowsing)
IconButton( IconButton(
icon: const Icon(AIcons.search), icon: Icon(AIcons.search),
onPressed: _goToSearch, onPressed: _goToSearch,
), ),
if (collection.isSelecting) if (collection.isSelecting)
@ -190,15 +190,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
if (collection.isBrowsing) ...[ if (collection.isBrowsing) ...[
if (AvesApp.mode == AppMode.main) if (AvesApp.mode == AppMode.main)
if (kDebugMode) if (kDebugMode)
const PopupMenuItem( PopupMenuItem(
value: CollectionAction.refresh, value: CollectionAction.refresh,
child: MenuRow(text: 'Refresh', icon: AIcons.refresh), child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
), ),
const PopupMenuItem( PopupMenuItem(
value: CollectionAction.select, value: CollectionAction.select,
child: MenuRow(text: 'Select', icon: AIcons.select), child: MenuRow(text: 'Select', icon: AIcons.select),
), ),
const PopupMenuItem( PopupMenuItem(
value: CollectionAction.stats, value: CollectionAction.stats,
child: MenuRow(text: 'Stats', icon: AIcons.stats), child: MenuRow(text: 'Stats', icon: AIcons.stats),
), ),
@ -207,27 +207,27 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
PopupMenuItem( PopupMenuItem(
value: CollectionAction.copy, value: CollectionAction.copy,
enabled: hasSelection, enabled: hasSelection,
child: const MenuRow(text: 'Copy to album'), child: MenuRow(text: 'Copy to album'),
), ),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.move, value: CollectionAction.move,
enabled: hasSelection, enabled: hasSelection,
child: const MenuRow(text: 'Move to album'), child: MenuRow(text: 'Move to album'),
), ),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.refreshMetadata, value: CollectionAction.refreshMetadata,
enabled: hasSelection, enabled: hasSelection,
child: const MenuRow(text: 'Refresh metadata'), child: MenuRow(text: 'Refresh metadata'),
), ),
const PopupMenuDivider(), PopupMenuDivider(),
const PopupMenuItem( PopupMenuItem(
value: CollectionAction.selectAll, value: CollectionAction.selectAll,
child: MenuRow(text: 'Select all'), child: MenuRow(text: 'Select all'),
), ),
PopupMenuItem( PopupMenuItem(
value: CollectionAction.selectNone, value: CollectionAction.selectNone,
enabled: hasSelection, enabled: hasSelection,
child: const MenuRow(text: 'Select none'), child: MenuRow(text: 'Select none'),
), ),
] ]
]; ];
@ -252,7 +252,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.sortByName, value: CollectionAction.sortByName,
child: MenuRow(text: 'Sort by name', checked: collection.sortFactor == SortFactor.name), child: MenuRow(text: 'Sort by name', checked: collection.sortFactor == SortFactor.name),
), ),
const PopupMenuDivider(), PopupMenuDivider(),
]; ];
} }
@ -271,7 +271,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
value: CollectionAction.groupByDay, value: CollectionAction.groupByDay,
child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day), child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day),
), ),
const PopupMenuDivider(), PopupMenuDivider(),
] ]
: []; : [];
} }

View file

@ -24,10 +24,10 @@ class EmptyContent extends StatelessWidget {
size: 64, size: 64,
color: color, color: color,
), ),
const SizedBox(height: 16), SizedBox(height: 16),
Text( Text(
text, text,
style: const TextStyle( style: TextStyle(
color: color, color: color,
fontSize: 22, fontSize: 22,
fontFamily: 'Concourse', fontFamily: 'Concourse',

View file

@ -18,7 +18,7 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
super(key: key); super(key: key);
@override @override
final Size preferredSize = const Size.fromHeight(preferredHeight); final Size preferredSize = Size.fromHeight(preferredHeight);
@override @override
_FilterBarState createState() => _FilterBarState(); _FilterBarState createState() => _FilterBarState();
@ -85,8 +85,8 @@ class _FilterBarState extends State<FilterBar> {
key: _animatedListKey, key: _animatedListKey,
initialItemCount: widget.filters.length, initialItemCount: widget.filters.length,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(), physics: BouncingScrollPhysics(),
padding: const EdgeInsets.only(left: 8), padding: EdgeInsets.only(left: 8),
itemBuilder: (context, index, animation) { itemBuilder: (context, index, animation) {
if (index >= widget.filters.length) return null; if (index >= widget.filters.length) return null;
return _buildChip(widget.filters.toList()[index]); return _buildChip(widget.filters.toList()[index]);
@ -98,7 +98,7 @@ class _FilterBarState extends State<FilterBar> {
Padding _buildChip(CollectionFilter filter) { Padding _buildChip(CollectionFilter filter) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: EdgeInsets.only(right: 8),
child: Center( child: Center(
child: AvesFilterChip( child: AvesFilterChip(
key: ValueKey(filter), key: ValueKey(filter),

View file

@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget {
leading: albumIcon, leading: albumIcon,
title: albumName, title: albumName,
trailing: androidFileUtils.isOnRemovableStorage(folderPath) trailing: androidFileUtils.isOnRemovableStorage(folderPath)
? const Icon( ? Icon(
AIcons.removableStorage, AIcons.removableStorage,
size: 16, size: 16,
color: Color(0xFF757575), color: Color(0xFF757575),

View file

@ -54,7 +54,7 @@ class SectionHeader extends StatelessWidget {
height: height, height: height,
child: header, child: header,
) )
: const SizedBox.shrink(); : SizedBox.shrink();
} }
Widget _buildAlbumSectionHeader() { Widget _buildAlbumSectionHeader() {
@ -128,51 +128,68 @@ class TitleSectionHeader extends StatelessWidget {
return Container( return Container(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
padding: padding, padding: padding,
constraints: const BoxConstraints(minHeight: leadingDimension), constraints: BoxConstraints(minHeight: leadingDimension),
child: Text.rich( child: GestureDetector(
TextSpan( onTap: () => _toggleSectionSelection(context),
children: [ child: Text.rich(
WidgetSpan( TextSpan(
alignment: widgetSpanAlignment, children: [
child: SectionSelectableLeading(
sectionKey: sectionKey,
browsingBuilder: leading != null
? (context) => Container(
padding: leadingPadding,
width: leadingDimension,
height: leadingDimension,
child: leading,
)
: null,
),
),
TextSpan(
text: title,
style: Constants.titleTextStyle,
),
if (trailing != null)
WidgetSpan( WidgetSpan(
alignment: widgetSpanAlignment, alignment: widgetSpanAlignment,
child: Container( child: SectionSelectableLeading(
padding: trailingPadding, sectionKey: sectionKey,
child: trailing, browsingBuilder: leading != null
? (context) => Container(
padding: leadingPadding,
width: leadingDimension,
height: leadingDimension,
child: leading,
)
: null,
onPressed: () => _toggleSectionSelection(context),
), ),
), ),
], TextSpan(
text: title,
style: Constants.titleTextStyle,
),
if (trailing != null)
WidgetSpan(
alignment: widgetSpanAlignment,
child: Container(
padding: trailingPadding,
child: trailing,
),
),
],
),
), ),
), ),
); );
} }
void _toggleSectionSelection(BuildContext context) {
final collection = Provider.of<CollectionLens>(context, listen: false);
final sectionEntries = collection.sections[sectionKey];
final selected = collection.isSelected(sectionEntries);
if (selected) {
collection.removeFromSelection(sectionEntries);
} else {
collection.addToSelection(sectionEntries);
}
}
} }
class SectionSelectableLeading extends StatelessWidget { class SectionSelectableLeading extends StatelessWidget {
final dynamic sectionKey; final dynamic sectionKey;
final WidgetBuilder browsingBuilder; final WidgetBuilder browsingBuilder;
final VoidCallback onPressed;
const SectionSelectableLeading({ const SectionSelectableLeading({
Key key, Key key,
@required this.sectionKey, @required this.sectionKey,
@required this.browsingBuilder, @required this.browsingBuilder,
@required this.onPressed,
}) : super(key: key); }) : super(key: key);
static const leadingDimension = TitleSectionHeader.leadingDimension; static const leadingDimension = TitleSectionHeader.leadingDimension;
@ -196,18 +213,12 @@ class SectionSelectableLeading extends StatelessWidget {
), ),
child: IconButton( child: IconButton(
iconSize: 26, iconSize: 26,
padding: const EdgeInsets.only(top: 1), padding: EdgeInsets.only(top: 1),
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
icon: Icon(selected ? AIcons.selected : AIcons.unselected), icon: Icon(selected ? AIcons.selected : AIcons.unselected),
onPressed: () { onPressed: onPressed,
if (selected) {
collection.removeFromSelection(sectionEntries);
} else {
collection.addToSelection(sectionEntries);
}
},
tooltip: selected ? 'Deselect section' : 'Select section', tooltip: selected ? 'Deselect section' : 'Select section',
constraints: const BoxConstraints( constraints: BoxConstraints(
minHeight: leadingDimension, minHeight: leadingDimension,
minWidth: leadingDimension, minWidth: leadingDimension,
), ),
@ -225,7 +236,7 @@ class SectionSelectableLeading extends StatelessWidget {
); );
}, },
) )
: browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension); : browsingBuilder?.call(context) ?? SizedBox(height: leadingDimension);
return AnimatedSwitcher( return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation, duration: Durations.sectionHeaderAnimation,
switchInCurve: Curves.easeInOut, switchInCurve: Curves.easeInOut,

View file

@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/album/grid/header_generic.dart'; import 'package:aves/widgets/album/grid/header_generic.dart';
import 'package:aves/widgets/album/grid/tile_extent_manager.dart'; import 'package:aves/widgets/album/grid/tile_extent_manager.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -110,7 +109,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
sectionKey: sectionKey, sectionKey: sectionKey,
height: headerExtent, height: headerExtent,
) )
: const SizedBox.shrink(); : SizedBox.shrink();
} }
} }
@ -145,25 +144,6 @@ class SectionedListLayout {
final top = sectionLayout.indexToLayoutOffset(listIndex); final top = sectionLayout.indexToLayoutOffset(listIndex);
return Rect.fromLTWH(left, top, tileExtent, tileExtent); return Rect.fromLTWH(left, top, tileExtent, tileExtent);
} }
int rowIndex(dynamic sectionKey, List<int> builtIds) {
if (!collection.sections.containsKey(sectionKey)) return null;
final section = collection.sections[sectionKey];
final firstId = builtIds.first;
final firstIndexInSection = section.indexWhere((entry) => entry.contentId == firstId);
if (firstIndexInSection % columnCount != 0) return null;
final collectionIds = section.skip(firstIndexInSection).take(builtIds.length).map((entry) => entry.contentId);
final eq = const IterableEquality().equals;
if (eq(builtIds, collectionIds)) {
final sectionLayout = sectionLayouts.firstWhere((section) => section.sectionKey == sectionKey, orElse: () => null);
if (sectionLayout == null) return null;
return sectionLayout.firstIndex + 1 + firstIndexInSection ~/ columnCount;
}
return null;
}
} }
class SectionLayout { class SectionLayout {

View file

@ -26,7 +26,7 @@ class CollectionListSliver extends StatelessWidget {
(context, index) { (context, index) {
if (index >= childCount) return null; if (index >= childCount) return null;
final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null); final sectionLayout = sectionLayouts.firstWhere((section) => section.hasChild(index), orElse: () => null);
return sectionLayout?.builder(context, index) ?? const SizedBox.shrink(); return sectionLayout?.builder(context, index) ?? SizedBox.shrink();
}, },
childCount: childCount, childCount: childCount,
addAutomaticKeepAlives: false, addAutomaticKeepAlives: false,

View file

@ -62,7 +62,7 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
scrollableBox.hitTest(result, position: details.localFocalPoint); scrollableBox.hitTest(result, position: details.localFocalPoint);
// find `RenderObject`s at the gesture focal point // find `RenderObject`s at the gesture focal point
final firstOf = <T>(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T; T firstOf<T>(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T;
final renderMetaData = firstOf<RenderMetaData>(result); final renderMetaData = firstOf<RenderMetaData>(result);
// abort if we cannot find an image to show on overlay // abort if we cannot find an image to show on overlay
if (renderMetaData == null) return; if (renderMetaData == null) return;
@ -192,7 +192,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
], ],
), ),
) )
: const BoxDecoration( : BoxDecoration(
// provide dummy gradient to lerp to the other one during animation // provide dummy gradient to lerp to the other one during animation
gradient: RadialGradient( gradient: RadialGradient(
colors: [ colors: [

View file

@ -23,7 +23,7 @@ class ExpandableFilterRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (filters.isEmpty) return const SizedBox.shrink(); if (filters.isEmpty) return SizedBox.shrink();
final hasTitle = title != null && title.isNotEmpty; final hasTitle = title != null && title.isNotEmpty;
@ -32,7 +32,7 @@ class ExpandableFilterRow extends StatelessWidget {
Widget titleRow; Widget titleRow;
if (hasTitle) { if (hasTitle) {
titleRow = Padding( titleRow = Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Row( child: Row(
children: [ children: [
Text( Text(
@ -52,7 +52,7 @@ class ExpandableFilterRow extends StatelessWidget {
final filtersList = filters.toList(); final filtersList = filters.toList();
final wrap = Container( final wrap = Container(
key: ValueKey('wrap$title'), key: ValueKey('wrap$title'),
padding: const EdgeInsets.symmetric(horizontal: horizontalPadding), padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
// specify transparent as a workaround to prevent // specify transparent as a workaround to prevent
// chip border clipping when the floating app bar is fading // chip border clipping when the floating app bar is fading
color: Colors.transparent, color: Colors.transparent,
@ -75,8 +75,8 @@ class ExpandableFilterRow extends StatelessWidget {
height: AvesFilterChip.minChipHeight, height: AvesFilterChip.minChipHeight,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(), physics: BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: horizontalPadding), padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= filtersList.length) return null; if (index >= filtersList.length) return null;
final filter = filtersList[index]; final filter = filtersList[index];
@ -85,7 +85,7 @@ class ExpandableFilterRow extends StatelessWidget {
onPressed: onPressed, onPressed: onPressed,
); );
}, },
separatorBuilder: (context, index) => const SizedBox(width: 8), separatorBuilder: (context, index) => SizedBox(width: 8),
itemCount: filtersList.length, itemCount: filtersList.length,
), ),
); );

View file

@ -44,7 +44,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
return [ return [
if (query.isNotEmpty) if (query.isNotEmpty)
IconButton( IconButton(
icon: const Icon(AIcons.clear), icon: Icon(AIcons.clear),
onPressed: () { onPressed: () {
query = ''; query = '';
showSuggestions(context); showSuggestions(context);
@ -57,23 +57,23 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
@override @override
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
final upQuery = query.trim().toUpperCase(); final upQuery = query.trim().toUpperCase();
final containQuery = (String s) => s.toUpperCase().contains(upQuery); bool containQuery(String s) => s.toUpperCase().contains(upQuery);
return SafeArea( return SafeArea(
child: ValueListenableBuilder<String>( child: ValueListenableBuilder<String>(
valueListenable: expandedSectionNotifier, valueListenable: expandedSectionNotifier,
builder: (context, expandedSection, child) { builder: (context, expandedSection, child) {
return ListView( return ListView(
padding: const EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
children: [ children: [
_buildFilterRow( _buildFilterRow(
context: context, context: context,
filters: [ filters: [
_buildQueryFilter(false), _buildQueryFilter(false),
FavouriteFilter(), FavouriteFilter(),
MimeFilter(MimeTypes.ANY_IMAGE), MimeFilter(MimeTypes.anyImage),
MimeFilter(MimeTypes.ANY_VIDEO), MimeFilter(MimeTypes.anyVideo),
MimeFilter(MimeFilter.animated), MimeFilter(MimeFilter.animated),
MimeFilter(MimeTypes.SVG), MimeFilter(MimeTypes.svg),
].where((f) => f != null && containQuery(f.label)), ].where((f) => f != null && containQuery(f.label)),
), ),
StreamBuilder( StreamBuilder(
@ -135,7 +135,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
// and possibly trigger a rebuild here // and possibly trigger a rebuild here
_select(context, _buildQueryFilter(true)); _select(context, _buildQueryFilter(true));
}); });
return const SizedBox.shrink(); return SizedBox.shrink();
} }
QueryFilter _buildQueryFilter(bool colorful) { QueryFilter _buildQueryFilter(bool colorful) {

View file

@ -57,7 +57,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = Durations.thumbnailOverlayAnimation; const duration = Durations.thumbnailOverlayAnimation;
final fontSize = min(14.0, (extent / 8)).roundToDouble(); final fontSize = min(14.0, (extent / 8)).roundToDouble();
final iconSize = fontSize * 2; final iconSize = fontSize * 2;
final collection = Provider.of<CollectionLens>(context); final collection = Provider.of<CollectionLens>(context);
@ -75,7 +75,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
icon: selected ? AIcons.selected : AIcons.unselected, icon: selected ? AIcons.selected : AIcons.unselected,
size: iconSize, size: iconSize,
) )
: const SizedBox.shrink(); : SizedBox.shrink();
child = AnimatedSwitcher( child = AnimatedSwitcher(
duration: duration, duration: duration,
switchInCurve: Curves.easeOutBack, switchInCurve: Curves.easeOutBack,
@ -95,7 +95,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
return child; return child;
}, },
) )
: const SizedBox.shrink(); : SizedBox.shrink();
return AnimatedSwitcher( return AnimatedSwitcher(
duration: duration, duration: duration,
child: child, child: child,

View file

@ -81,7 +81,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fastImage = Image( final fastImage = Image(
key: const ValueKey('LQ'), key: ValueKey('LQ'),
image: _fastThumbnailProvider, image: _fastThumbnailProvider,
width: extent, width: extent,
height: extent, height: extent,
@ -90,7 +90,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
final image = _sizedThumbnailProvider == null final image = _sizedThumbnailProvider == null
? fastImage ? fastImage
: Image( : Image(
key: const ValueKey('HQ'), key: ValueKey('HQ'),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child; if (wasSynchronouslyLoaded) return child;
return AnimatedSwitcher( return AnimatedSwitcher(

View file

@ -36,7 +36,7 @@ class ThumbnailCollection extends StatelessWidget {
final mqSize = mq.item1; final mqSize = mq.item1;
final mqHorizontalPadding = mq.item2; final mqHorizontalPadding = mq.item2;
if (mqSize.isEmpty) return const SizedBox.shrink(); if (mqSize.isEmpty) return SizedBox.shrink();
TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier); TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier);
final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2; final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2;
@ -159,7 +159,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
primary: true, primary: true,
// workaround to prevent scrolling the app bar away // workaround to prevent scrolling the app bar away
// when there is no content and we use `SliverFillRemaining` // when there is no content and we use `SliverFillRemaining`
physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : const SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()), physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
cacheExtent: widget.cacheExtent, cacheExtent: widget.cacheExtent,
slivers: [ slivers: [
appBar, appBar,
@ -168,7 +168,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
child: _buildEmptyCollectionPlaceholder(collection), child: _buildEmptyCollectionPlaceholder(collection),
hasScrollBody: false, hasScrollBody: false,
) )
: const CollectionListSliver(), : CollectionListSliver(),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (context, mq) => mq.viewInsets.bottom, selector: (context, mq) => mq.viewInsets.bottom,
@ -211,22 +211,22 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
valueListenable: collection.source.stateNotifier, valueListenable: collection.source.stateNotifier,
builder: (context, sourceState, child) { builder: (context, sourceState, child) {
if (sourceState == SourceState.loading) { if (sourceState == SourceState.loading) {
return const SizedBox.shrink(); return SizedBox.shrink();
} }
if (collection.filters.any((filter) => filter is FavouriteFilter)) { if (collection.filters.any((filter) => filter is FavouriteFilter)) {
return const EmptyContent( return EmptyContent(
icon: AIcons.favourite, icon: AIcons.favourite,
text: 'No favourites', text: 'No favourites',
); );
} }
debugPrint('collection.filters=${collection.filters}'); debugPrint('collection.filters=${collection.filters}');
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO)) { if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
return const EmptyContent( return EmptyContent(
icon: AIcons.video, icon: AIcons.video,
text: 'No videos', text: 'No videos',
); );
} }
return const EmptyContent( return EmptyContent(
icon: AIcons.image, icon: AIcons.image,
text: 'No images', text: 'No images',
); );

View file

@ -46,7 +46,7 @@ class _AppDrawerState extends State<AppDrawer> {
), ),
), ),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
color: Theme.of(context).accentColor, color: Theme.of(context).accentColor,
child: SafeArea( child: SafeArea(
child: Column( child: Column(
@ -58,8 +58,8 @@ class _AppDrawerState extends State<AppDrawer> {
spacing: 16, spacing: 16,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
const AvesLogo(size: 64), AvesLogo(size: 64),
const Text( Text(
'Aves', 'Aves',
style: TextStyle( style: TextStyle(
fontSize: 44, fontSize: 44,
@ -77,19 +77,19 @@ class _AppDrawerState extends State<AppDrawer> {
final allMediaEntry = _FilteredCollectionNavTile( final allMediaEntry = _FilteredCollectionNavTile(
source: source, source: source,
leading: const Icon(AIcons.allMedia), leading: Icon(AIcons.allMedia),
title: 'All media', title: 'All media',
filter: null, filter: null,
); );
final videoEntry = _FilteredCollectionNavTile( final videoEntry = _FilteredCollectionNavTile(
source: source, source: source,
leading: const Icon(AIcons.video), leading: Icon(AIcons.video),
title: 'Videos', title: 'Videos',
filter: MimeFilter(MimeTypes.ANY_VIDEO), filter: MimeFilter(MimeTypes.anyVideo),
); );
final favouriteEntry = _FilteredCollectionNavTile( final favouriteEntry = _FilteredCollectionNavTile(
source: source, source: source,
leading: const Icon(AIcons.favourite), leading: Icon(AIcons.favourite),
title: 'Favourites', title: 'Favourites',
filter: FavouriteFilter(), filter: FavouriteFilter(),
); );
@ -97,8 +97,8 @@ class _AppDrawerState extends State<AppDrawer> {
top: false, top: false,
bottom: false, bottom: false,
child: ListTile( child: ListTile(
leading: const Icon(AIcons.info), leading: Icon(AIcons.info),
title: const Text('About'), title: Text('About'),
onTap: () => _goToAbout(context), onTap: () => _goToAbout(context),
), ),
); );
@ -109,20 +109,20 @@ class _AppDrawerState extends State<AppDrawer> {
videoEntry, videoEntry,
favouriteEntry, favouriteEntry,
_buildSpecialAlbumSection(), _buildSpecialAlbumSection(),
const Divider(), Divider(),
_buildRegularAlbumSection(), _buildRegularAlbumSection(),
_buildCountrySection(), _buildCountrySection(),
_buildTagSection(), _buildTagSection(),
const Divider(), Divider(),
aboutEntry, aboutEntry,
if (kDebugMode) ...[ if (kDebugMode) ...[
const Divider(), Divider(),
SafeArea( SafeArea(
top: false, top: false,
bottom: false, bottom: false,
child: ListTile( child: ListTile(
leading: const Icon(AIcons.debug), leading: Icon(AIcons.debug),
title: const Text('Debug'), title: Text('Debug'),
onTap: () => _goToDebug(context), onTap: () => _goToDebug(context),
), ),
), ),
@ -157,7 +157,7 @@ class _AppDrawerState extends State<AppDrawer> {
leading: IconUtils.getAlbumIcon(context: context, album: album), leading: IconUtils.getAlbumIcon(context: context, album: album),
title: uniqueName, title: uniqueName,
trailing: androidFileUtils.isOnRemovableStorage(album) trailing: androidFileUtils.isOnRemovableStorage(album)
? const Icon( ? Icon(
AIcons.removableStorage, AIcons.removableStorage,
size: 16, size: 16,
color: Colors.grey, color: Colors.grey,
@ -177,10 +177,10 @@ class _AppDrawerState extends State<AppDrawer> {
return type != AlbumType.regular && type != AlbumType.app; return type != AlbumType.regular && type != AlbumType.app;
}); });
if (specialAlbums.isEmpty) return const SizedBox.shrink(); if (specialAlbums.isEmpty) return SizedBox.shrink();
return Column( return Column(
children: [ children: [
const Divider(), Divider(),
...specialAlbums.map((album) => _buildAlbumEntry(album, dense: false)), ...specialAlbums.map((album) => _buildAlbumEntry(album, dense: false)),
], ],
); );
@ -192,8 +192,8 @@ class _AppDrawerState extends State<AppDrawer> {
top: false, top: false,
bottom: false, bottom: false,
child: ListTile( child: ListTile(
leading: const Icon(AIcons.album), leading: Icon(AIcons.album),
title: const Text('Albums'), title: Text('Albums'),
trailing: StreamBuilder( trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -214,8 +214,8 @@ class _AppDrawerState extends State<AppDrawer> {
top: false, top: false,
bottom: false, bottom: false,
child: ListTile( child: ListTile(
leading: const Icon(AIcons.location), leading: Icon(AIcons.location),
title: const Text('Countries'), title: Text('Countries'),
trailing: StreamBuilder( trailing: StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(), stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -236,8 +236,8 @@ class _AppDrawerState extends State<AppDrawer> {
top: false, top: false,
bottom: false, bottom: false,
child: ListTile( child: ListTile(
leading: const Icon(AIcons.tag), leading: Icon(AIcons.tag),
title: const Text('Tags'), title: Text('Tags'),
trailing: StreamBuilder( trailing: StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(), stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -263,7 +263,7 @@ class _AppDrawerState extends State<AppDrawer> {
title: 'Albums', title: 'Albums',
filterEntries: source.getAlbumEntries(), filterEntries: source.getAlbumEntries(),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => const EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: 'No albums', text: 'No albums',
), ),
@ -282,7 +282,7 @@ class _AppDrawerState extends State<AppDrawer> {
title: 'Countries', title: 'Countries',
filterEntries: source.getCountryEntries(), filterEntries: source.getCountryEntries(),
filterBuilder: (s) => LocationFilter(LocationLevel.country, s), filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
emptyBuilder: () => const EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.location, icon: AIcons.location,
text: 'No countries', text: 'No countries',
), ),
@ -301,7 +301,7 @@ class _AppDrawerState extends State<AppDrawer> {
title: 'Tags', title: 'Tags',
filterEntries: source.getTagEntries(), filterEntries: source.getTagEntries(),
filterBuilder: (s) => TagFilter(s), filterBuilder: (s) => TagFilter(s),
emptyBuilder: () => const EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.tag, icon: AIcons.tag,
text: 'No tags', text: 'No tags',
), ),

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -10,19 +12,17 @@ class CreateAlbumDialog extends StatefulWidget {
class _CreateAlbumDialogState extends State<CreateAlbumDialog> { class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
Set<StorageVolume> allVolumes; final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
StorageVolume primaryVolume, selectedVolume; Set<StorageVolume> _allVolumes;
StorageVolume _primaryVolume, _selectedVolume;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_allVolumes = androidFileUtils.storageVolumes;
// TODO TLAD improve new album default name _primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first);
_nameController.text = 'Album 1'; _selectedVolume = _primaryVolume;
_initAlbumName();
allVolumes = androidFileUtils.storageVolumes;
primaryVolume = allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => allVolumes.first);
selectedVolume = primaryVolume;
} }
@override @override
@ -34,20 +34,20 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: const Text('New Album'), title: Text('New Album'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (allVolumes.length > 1) ...[ if (_allVolumes.length > 1) ...[
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Storage:'), Text('Storage:'),
const SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: DropdownButton<StorageVolume>( child: DropdownButton<StorageVolume>(
isExpanded: true, isExpanded: true,
items: allVolumes items: _allVolumes
.map((volume) => DropdownMenuItem( .map((volume) => DropdownMenuItem(
value: volume, value: volume,
child: Text( child: Text(
@ -58,37 +58,67 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
), ),
)) ))
.toList(), .toList(),
value: selectedVolume, value: _selectedVolume,
onChanged: (volume) => setState(() => selectedVolume = volume), onChanged: (volume) {
_selectedVolume = volume;
_checkAlbumExists();
setState(() {});
},
), ),
), ),
], ],
), ),
const SizedBox(height: 16), SizedBox(height: 16),
], ],
TextField( ValueListenableBuilder<bool>(
controller: _nameController, valueListenable: _existsNotifier,
// autofocus: true, builder: (context, exists, child) {
), return TextField(
controller: _nameController,
decoration: InputDecoration(
helperText: exists ? 'Album already exists' : '',
),
onChanged: (_) => _checkAlbumExists(),
onSubmitted: (_) => _submit(context),
);
}),
], ],
), ),
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), contentPadding: EdgeInsets.fromLTRB(24, 20, 24, 0),
actions: [ actions: [
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()), child: Text('Cancel'.toUpperCase()),
), ),
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context, _buildAlbumPath()), onPressed: () => _submit(context),
child: Text('Create'.toUpperCase()), child: Text('Create'.toUpperCase()),
), ),
], ],
); );
} }
String _buildAlbumPath() { String _buildAlbumPath(String name) {
final newName = _nameController.text; if (name == null || name.isEmpty) return '';
if (newName == null || newName.isEmpty) return null; return join(_selectedVolume.path, 'Pictures', name);
return join(selectedVolume == primaryVolume ? androidFileUtils.dcimPath : selectedVolume.path, newName);
} }
Future<void> _initAlbumName() async {
var count = 1;
while (true) {
var name = 'Album $count';
if (!await Directory(_buildAlbumPath(name)).exists()) {
_nameController.text = name;
return;
}
count++;
}
}
Future<void> _checkAlbumExists() async {
final path = _buildAlbumPath(_nameController.text);
_existsNotifier.value = path.isEmpty ? false : await Directory(path).exists();
}
void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text));
} }

View file

@ -118,9 +118,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _showDeleteDialog(BuildContext context, ImageEntry entry) async { Future<void> _showDeleteDialog(BuildContext context, ImageEntry entry) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (BuildContext context) { builder: (context) {
return AlertDialog( return AlertDialog(
content: const Text('Are you sure?'), content: Text('Are you sure?'),
actions: [ actions: [
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),

View file

@ -11,7 +11,7 @@ mixin FeedbackMixin {
void showFeedback(BuildContext context, String message) { void showFeedback(BuildContext context, String message) {
_flushbar = Flushbar( _flushbar = Flushbar(
message: message, message: message,
margin: const EdgeInsets.all(8), margin: EdgeInsets.all(8),
borderRadius: 8, borderRadius: 8,
borderColor: Colors.white30, borderColor: Colors.white30,
borderWidth: 0.5, borderWidth: 0.5,

View file

@ -1,34 +1,30 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
mixin PermissionAwareMixin { mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) { Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) {
return checkStoragePermissionForPaths(context, entries.where((e) => e.path != null).map((e) => e.path)); return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
} }
Future<bool> checkStoragePermissionForPaths(BuildContext context, Iterable<String> paths) async { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet(); while (true) {
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>( final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
volumes.map( if (dirs == null) return false;
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then( if (dirs.isEmpty) return true;
(granted) => Tuple2(volume, granted),
), final dir = dirs.first;
), final volumePath = dir['volumePath'] as String;
)) final volumeDescription = dir['volumeDescription'] as String;
.where((t) => t.item2) final relativeDir = dir['relativeDir'] as String;
.map((t) => t.item1) final dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir';
.toList();
while (ungrantedVolumes.isNotEmpty) {
final volume = ungrantedVolumes.first;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (BuildContext context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: const Text('Storage Volume Access'), title: Text('Storage Volume Access'),
content: Text('Please select the root directory of “${volume.description}” in the next screen, so that this app can access it and complete your request.'), content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'),
actions: [ actions: [
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -45,15 +41,11 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
final granted = await AndroidFileService.requestVolumeAccess(volume.path); final granted = await AndroidFileService.requestVolumeAccess(volumePath);
debugPrint('$runtimeType _checkStoragePermission with volume=${volume.path} got granted=$granted'); if (!granted) {
if (granted) {
ungrantedVolumes.remove(volume);
} else {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; return false;
} }
} }
return true;
} }
} }

View file

@ -69,7 +69,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
return FilterGridPage( return FilterGridPage(
source: source, source: source,
appBar: SliverAppBar( appBar: SliverAppBar(
leading: const BackButton(), leading: BackButton(),
title: Text(copy ? 'Copy to Album' : 'Move to Album'), title: Text(copy ? 'Copy to Album' : 'Move to Album'),
actions: [ actions: [
IconButton( IconButton(
@ -90,7 +90,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
), ),
filterEntries: source.getAlbumEntries(), filterEntries: source.getAlbumEntries(),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => const EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: 'No albums', text: 'No albums',
), ),
@ -100,26 +100,26 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
), ),
); );
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForPaths(context, [destinationAlbum])) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
final selection = collection.selection.toList(); final selection = collection.selection.toList();
if (!await checkStoragePermission(context, selection)) return; if (!await checkStoragePermission(context, selection)) return;
_showOpReport( _showOpReport<MoveOpEvent>(
context: context, context: context,
selection: selection, selection: selection,
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum), opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
onDone: (Set<MoveOpEvent> processed) async { onDone: (processed) async {
debugPrint('$runtimeType _moveSelection onDone'); debugPrint('$runtimeType _moveSelection onDone');
final movedOps = processed.where((e) => e.success); final movedOps = processed.where((e) => e.success);
final movedCount = movedOps.length; final movedCount = movedOps.length;
final selectionCount = selection.length; final selectionCount = selection.length;
if (movedCount < selectionCount) { if (movedCount < selectionCount) {
final count = selectionCount - movedCount; final count = selectionCount - movedCount;
showFeedback(context, 'Failed to move ${Intl.plural(count, one: '${count} item', other: '${count} items')}'); showFeedback(context, 'Failed to move ${Intl.plural(count, one: '$count item', other: '$count items')}');
} else { } else {
final count = movedCount; final count = movedCount;
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '${count} item', other: '${count} items')}'); showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
} }
if (movedCount > 0) { if (movedCount > 0) {
final fromAlbums = <String>{}; final fromAlbums = <String>{};
@ -187,9 +187,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (BuildContext context) { builder: (context) {
return AlertDialog( return AlertDialog(
content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these ${count} items')}?'), content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'),
actions: [ actions: [
FlatButton( FlatButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -207,17 +207,17 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
if (!await checkStoragePermission(context, selection)) return; if (!await checkStoragePermission(context, selection)) return;
_showOpReport( _showOpReport<ImageOpEvent>(
context: context, context: context,
selection: selection, selection: selection,
opStream: ImageFileService.delete(selection), opStream: ImageFileService.delete(selection),
onDone: (Set<ImageOpEvent> processed) { onDone: (processed) {
final deletedUris = processed.where((e) => e.success).map((e) => e.uri); final deletedUris = processed.where((e) => e.success).map((e) => e.uri);
final deletedCount = deletedUris.length; final deletedCount = deletedUris.length;
final selectionCount = selection.length; final selectionCount = selection.length;
if (deletedCount < selectionCount) { if (deletedCount < selectionCount) {
final count = selectionCount - deletedCount; final count = selectionCount - deletedCount;
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '${count} item', other: '${count} items')}'); showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
} }
if (deletedCount > 0) { if (deletedCount > 0) {
collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri))); collection.source.removeEntries(selection.where((e) => deletedUris.contains(e.uri)));
@ -242,9 +242,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
// do not handle completion inside `StreamBuilder` // do not handle completion inside `StreamBuilder`
// as it could be called multiple times // as it could be called multiple times
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed)); Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
opStream.listen( opStream.listen(
(event) => processed.add(event), processed.add,
onError: (error) { onError: (error) {
debugPrint('_showOpReport error=$error'); debugPrint('_showOpReport error=$error');
onComplete(); onComplete();
@ -258,7 +258,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
child: StreamBuilder<T>( child: StreamBuilder<T>(
stream: opStream, stream: opStream,
builder: (context, snapshot) { builder: (context, snapshot) {
Widget child = const SizedBox.shrink(); Widget child = SizedBox.shrink();
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
final percent = processed.length.toDouble() / selection.length; final percent = processed.length.toDouble() / selection.length;
child = CircularPercentIndicator( child = CircularPercentIndicator(

View file

@ -32,10 +32,10 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
), ),
), ),
child: sourceState == SourceState.ready child: sourceState == SourceState.ready
? const SizedBox.shrink() ? SizedBox.shrink()
: SourceStateSubtitle( : SourceStateSubtitle(
source: source, source: source,
), ),
); );
}, },
), ),
@ -68,26 +68,26 @@ class SourceStateSubtitle extends StatelessWidget {
} }
final subtitleStyle = Theme.of(context).textTheme.caption; final subtitleStyle = Theme.of(context).textTheme.caption;
return subtitle == null return subtitle == null
? const SizedBox.shrink() ? SizedBox.shrink()
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(subtitle, style: subtitleStyle), Text(subtitle, style: subtitleStyle),
StreamBuilder<ProgressEvent>( StreamBuilder<ProgressEvent>(
stream: source.progressStream, stream: source.progressStream,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink();
final progress = snapshot.data; final progress = snapshot.data;
return Padding( return Padding(
padding: const EdgeInsetsDirectional.only(start: 8), padding: EdgeInsetsDirectional.only(start: 8),
child: Text( child: Text(
'${progress.done}/${progress.total}', '${progress.done}/${progress.total}',
style: subtitleStyle.copyWith(color: Colors.white30), style: subtitleStyle.copyWith(color: Colors.white30),
),
);
},
), ),
); ],
}, );
),
],
);
} }
} }

View file

@ -66,7 +66,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasBackground = widget.background != null; final hasBackground = widget.background != null;
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon); final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon);
final trailing = widget.removable ? const Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null; final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null;
Widget content = Row( Widget content = Row(
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min, mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
@ -74,7 +74,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
children: [ children: [
if (leading != null) ...[ if (leading != null) ...[
leading, leading,
const SizedBox(width: AvesFilterChip.padding), SizedBox(width: AvesFilterChip.padding),
], ],
Flexible( Flexible(
child: Text( child: Text(
@ -85,7 +85,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
), ),
), ),
if (trailing != null) ...[ if (trailing != null) ...[
const SizedBox(width: AvesFilterChip.padding), SizedBox(width: AvesFilterChip.padding),
trailing, trailing,
], ],
], ],
@ -102,7 +102,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
} }
content = Padding( content = Padding(
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2), padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2),
child: content, child: content,
); );
@ -112,7 +112,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
color: Colors.black54, color: Colors.black54,
child: DefaultTextStyle( child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith( style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: const [ shadows: [
Shadow( Shadow(
color: Colors.black87, color: Colors.black87,
offset: Offset(0.5, 1.0), offset: Offset(0.5, 1.0),
@ -128,7 +128,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final borderRadius = AvesFilterChip.borderRadius; final borderRadius = AvesFilterChip.borderRadius;
Widget chip = Container( Widget chip = Container(
constraints: const BoxConstraints( constraints: BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,
maxWidth: AvesFilterChip.maxChipWidth, maxWidth: AvesFilterChip.maxChipWidth,
minHeight: AvesFilterChip.minChipHeight, minHeight: AvesFilterChip.minChipHeight,
@ -157,9 +157,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
} }
: null, : null,
borderRadius: borderRadius, borderRadius: borderRadius,
child: FutureBuilder( child: FutureBuilder<Color>(
future: _colorFuture, future: _colorFuture,
builder: (context, AsyncSnapshot<Color> snapshot) { builder: (context, snapshot) {
final outlineColor = snapshot.hasData ? snapshot.data : Colors.transparent; final outlineColor = snapshot.hasData ? snapshot.data : Colors.transparent;
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -171,7 +171,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
), ),
position: DecorationPosition.foreground, position: DecorationPosition.foreground,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
child: content, child: content,
), ),
); );

View file

@ -51,11 +51,11 @@ class MediaStoreSource extends CollectionSource {
var refreshCount = 10; var refreshCount = 10;
const refreshCountMax = 1000; const refreshCountMax = 1000;
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[]; final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[];
final addPendingEntries = () { void addPendingEntries() {
allNewEntries.addAll(pendingNewEntries); allNewEntries.addAll(pendingNewEntries);
addAll(pendingNewEntries); addAll(pendingNewEntries);
pendingNewEntries.clear(); pendingNewEntries.clear();
}; }
ImageFileService.getImageEntries(knownEntryMap).listen( ImageFileService.getImageEntries(knownEntryMap).listen(
(entry) { (entry) {
pendingNewEntries.add(entry); pendingNewEntries.add(entry);

View file

@ -131,10 +131,10 @@ class OverlayIcon extends StatelessWidget {
); );
return Container( return Container(
margin: const EdgeInsets.all(1), margin: EdgeInsets.all(1),
padding: text != null ? EdgeInsets.only(right: size / 4) : null, padding: text != null ? EdgeInsets.only(right: size / 4) : null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xBB000000), color: Color(0xBB000000),
borderRadius: BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(size), Radius.circular(size),
), ),
@ -146,7 +146,7 @@ class OverlayIcon extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
iconChild, iconChild,
const SizedBox(width: 2), SizedBox(width: 2),
Text(text), Text(text),
], ],
), ),

View file

@ -54,7 +54,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
} }
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeThumbnail(_cancellationKey); ImageFileService.resumeThumbnail(_cancellationKey);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }

View file

@ -23,7 +23,7 @@ class LinkChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTextStyle.merge( return DefaultTextStyle.merge(
style: (textStyle ?? const TextStyle()).copyWith(color: color), style: (textStyle ?? TextStyle()).copyWith(color: color),
child: InkWell( child: InkWell(
borderRadius: borderRadius, borderRadius: borderRadius,
onTap: () async { onTap: () async {
@ -32,16 +32,16 @@ class LinkChip extends StatelessWidget {
} }
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (leading != null) ...[ if (leading != null) ...[
leading, leading,
const SizedBox(width: 8), SizedBox(width: 8),
], ],
Text(text), Text(text),
const SizedBox(width: 8), SizedBox(width: 8),
Builder( Builder(
builder: (context) => Icon( builder: (context) => Icon(
AIcons.openInNew, AIcons.openInNew,

View file

@ -20,13 +20,13 @@ class MenuRow extends StatelessWidget {
if (checked != null) ...[ if (checked != null) ...[
Opacity( Opacity(
opacity: checked ? 1 : 0, opacity: checked ? 1 : 0,
child: const Icon(AIcons.checked), child: Icon(AIcons.checked),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
], ],
if (icon != null) ...[ if (icon != null) ...[
Icon(icon), Icon(icon),
const SizedBox(width: 8), SizedBox(width: 8),
], ],
Expanded(child: Text(text)), Expanded(child: Text(text)),
], ],

View file

@ -10,21 +10,21 @@ ScrollThumbBuilder avesScrollThumbBuilder({
@required Color backgroundColor, @required Color backgroundColor,
}) { }) {
final scrollThumb = Container( final scrollThumb = Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Colors.black26, color: Colors.black26,
borderRadius: BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(12.0), Radius.circular(12.0),
), ),
), ),
height: height, height: height,
margin: const EdgeInsets.only(right: .5), margin: EdgeInsets.only(right: .5),
padding: const EdgeInsets.all(2), padding: EdgeInsets.all(2),
child: ClipPath( child: ClipPath(
child: Container( child: Container(
width: 20.0, width: 20.0,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: const BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(12.0), Radius.circular(12.0),
), ),
), ),
@ -32,13 +32,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
clipper: ArrowClipper(), clipper: ArrowClipper(),
), ),
); );
return ( return (backgroundColor, thumbAnimation, labelAnimation, height, {labelText}) {
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Widget labelText,
}) {
return DraggableScrollbar.buildScrollThumbAndLabel( return DraggableScrollbar.buildScrollThumbAndLabel(
scrollThumb: scrollThumb, scrollThumb: scrollThumb,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,

View file

@ -12,7 +12,7 @@ class TransitionImage extends StatefulWidget {
final ImageProvider image; final ImageProvider image;
final double width, height; final double width, height;
final ValueListenable<double> animation; final ValueListenable<double> animation;
final gaplessPlayback = false; final bool gaplessPlayback = false;
const TransitionImage({ const TransitionImage({
@required this.image, @required this.image,

View file

@ -7,7 +7,6 @@ import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
@ -17,7 +16,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
import 'package:tuple/tuple.dart';
class DebugPage extends StatefulWidget { class DebugPage extends StatefulWidget {
final CollectionSource source; final CollectionSource source;
@ -35,7 +33,6 @@ class DebugPageState extends State<DebugPage> {
Future<List<CatalogMetadata>> _dbMetadataLoader; Future<List<CatalogMetadata>> _dbMetadataLoader;
Future<List<AddressDetails>> _dbAddressLoader; Future<List<AddressDetails>> _dbAddressLoader;
Future<List<FavouriteRow>> _dbFavouritesLoader; Future<List<FavouriteRow>> _dbFavouritesLoader;
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
Future<Map> _envLoader; Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.rawEntries; List<ImageEntry> get entries => widget.source.rawEntries;
@ -44,13 +41,6 @@ class DebugPageState extends State<DebugPage> {
void initState() { void initState() {
super.initState(); super.initState();
_startDbReport(); _startDbReport();
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
androidFileUtils.storageVolumes.map(
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
(value) => Tuple2(volume.path, !value),
),
),
);
_envLoader = AndroidAppService.getEnv(); _envLoader = AndroidAppService.getEnv();
} }
@ -61,8 +51,8 @@ class DebugPageState extends State<DebugPage> {
length: 4, length: 4,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Debug'), title: Text('Debug'),
bottom: const TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(icon: Icon(OMIcons.whatshot)), Tab(icon: Icon(OMIcons.whatshot)),
Tab(icon: Icon(OMIcons.settings)), Tab(icon: Icon(OMIcons.settings)),
@ -91,9 +81,9 @@ class DebugPageState extends State<DebugPage> {
final withGps = catalogued.where((entry) => entry.hasGps); final withGps = catalogued.where((entry) => entry.hasGps);
final located = withGps.where((entry) => entry.isLocated); final located = withGps.where((entry) => entry.isLocated);
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
const Text('Time dilation'), Text('Time dilation'),
Slider( Slider(
value: timeDilation, value: timeDilation,
onChanged: (v) => setState(() => timeDilation = v), onChanged: (v) => setState(() => timeDilation = v),
@ -102,24 +92,24 @@ class DebugPageState extends State<DebugPage> {
divisions: 9, divisions: 9,
label: '$timeDilation', label: '$timeDilation',
), ),
const Divider(), Divider(),
Text('Entries: ${entries.length}'), Text('Entries: ${entries.length}'),
Text('Catalogued: ${catalogued.length}'), Text('Catalogued: ${catalogued.length}'),
Text('With GPS: ${withGps.length}'), Text('With GPS: ${withGps.length}'),
Text('With address: ${located.length}'), Text('With address: ${located.length}'),
const Divider(), Divider(),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'), child: Text('Image cache:\n\t${imageCache.currentSize}/${imageCache.maximumSize} items\n\t${formatFilesize(imageCache.currentSizeBytes)}/${formatFilesize(imageCache.maximumSizeBytes)}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () { onPressed: () {
imageCache.clear(); imageCache.clear();
setState(() {}); setState(() {});
}, },
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
), ),
@ -128,138 +118,138 @@ class DebugPageState extends State<DebugPage> {
Expanded( Expanded(
child: Text('SVG cache: ${PictureProvider.cacheCount} items'), child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () { onPressed: () {
PictureProvider.clearCache(); PictureProvider.clearCache();
setState(() {}); setState(() {});
}, },
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
), ),
Row( Row(
children: [ children: [
const Expanded( Expanded(
child: Text('Glide disk cache: ?'), child: Text('Glide disk cache: ?'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => ImageFileService.clearSizedThumbnailDiskCache(), onPressed: ImageFileService.clearSizedThumbnailDiskCache,
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
), ),
const Divider(), Divider(),
FutureBuilder( FutureBuilder<int>(
future: _dbFileSizeLoader, future: _dbFileSizeLoader,
builder: (context, AsyncSnapshot<int> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB file size: ${formatFilesize(snapshot.data)}'), child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => metadataDb.reset().then((_) => _startDbReport()), onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
child: const Text('Reset'), child: Text('Reset'),
), ),
], ],
); );
}, },
), ),
FutureBuilder( FutureBuilder<List>(
future: _dbEntryLoader, future: _dbEntryLoader,
builder: (context, AsyncSnapshot<List> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB entry rows: ${snapshot.data.length}'), child: Text('DB entry rows: ${snapshot.data.length}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()), onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
); );
}, },
), ),
FutureBuilder( FutureBuilder<List>(
future: _dbDateLoader, future: _dbDateLoader,
builder: (context, AsyncSnapshot<List> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB date rows: ${snapshot.data.length}'), child: Text('DB date rows: ${snapshot.data.length}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()), onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
); );
}, },
), ),
FutureBuilder( FutureBuilder<List>(
future: _dbMetadataLoader, future: _dbMetadataLoader,
builder: (context, AsyncSnapshot<List> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB metadata rows: ${snapshot.data.length}'), child: Text('DB metadata rows: ${snapshot.data.length}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()), onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
); );
}, },
), ),
FutureBuilder( FutureBuilder<List>(
future: _dbAddressLoader, future: _dbAddressLoader,
builder: (context, AsyncSnapshot<List> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB address rows: ${snapshot.data.length}'), child: Text('DB address rows: ${snapshot.data.length}'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()), onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
); );
}, },
), ),
FutureBuilder( FutureBuilder<List>(
future: _dbFavouritesLoader, future: _dbFavouritesLoader,
builder: (context, AsyncSnapshot<List> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'), child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => favourites.clear().then((_) => _startDbReport()), onPressed: () => favourites.clear().then((_) => _startDbReport()),
child: const Text('Clear'), child: Text('Clear'),
), ),
], ],
); );
@ -271,17 +261,17 @@ class DebugPageState extends State<DebugPage> {
Widget _buildSettingsTabView() { Widget _buildSettingsTabView() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
Row( Row(
children: [ children: [
const Expanded( Expanded(
child: Text('Settings'), child: Text('Settings'),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
RaisedButton( RaisedButton(
onPressed: () => settings.reset().then((_) => setState(() {})), onPressed: () => settings.reset().then((_) => setState(() {})),
child: const Text('Reset'), child: Text('Reset'),
), ),
], ],
), ),
@ -297,46 +287,32 @@ class DebugPageState extends State<DebugPage> {
Widget _buildStorageTabView() { Widget _buildStorageTabView() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
FutureBuilder( ...androidFileUtils.storageVolumes.expand((v) => [
future: _volumePermissionLoader, Text(v.path),
builder: (context, AsyncSnapshot<List<Tuple2<String, bool>>> snapshot) { InfoRowGroup({
if (snapshot.hasError) return Text(snapshot.error.toString()); 'description': '${v.description}',
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); 'isEmulated': '${v.isEmulated}',
final permissions = snapshot.data; 'isPrimary': '${v.isPrimary}',
return Column( 'isRemovable': '${v.isRemovable}',
crossAxisAlignment: CrossAxisAlignment.start, 'state': '${v.state}',
children: [ }),
...androidFileUtils.storageVolumes.expand((v) => [ Divider(),
Text(v.path), ])
InfoRowGroup({
'description': '${v.description}',
'isEmulated': '${v.isEmulated}',
'isPrimary': '${v.isPrimary}',
'isRemovable': '${v.isRemovable}',
'state': '${v.state}',
'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}',
}),
const Divider(),
])
],
);
},
),
], ],
); );
} }
Widget _buildEnvTabView() { Widget _buildEnvTabView() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
FutureBuilder( FutureBuilder<Map>(
future: _envLoader, future: _envLoader,
builder: (context, AsyncSnapshot<Map> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null')));
return InfoRowGroup(data); return InfoRowGroup(data);
}, },

View file

@ -106,7 +106,7 @@ class FilterGridPage extends StatelessWidget {
hasScrollBody: false, hasScrollBody: false,
) )
: SliverPadding( : SliverPadding(
padding: const EdgeInsets.all(AvesFilterChip.outlineWidth), padding: EdgeInsets.all(AvesFilterChip.outlineWidth),
sliver: SliverGrid( sliver: SliverGrid(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, i) { (context, i) {
@ -132,7 +132,7 @@ class FilterGridPage extends StatelessWidget {
}, },
childCount: filterKeys.length, childCount: filterKeys.length,
), ),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: maxCrossAxisExtent, maxCrossAxisExtent: maxCrossAxisExtent,
mainAxisSpacing: 8, mainAxisSpacing: 8,
crossAxisSpacing: 8, crossAxisSpacing: 8,
@ -201,18 +201,18 @@ class DecoratedFilterChip extends StatelessWidget {
Widget _buildDetails(CollectionFilter filter) { Widget _buildDetails(CollectionFilter filter) {
final count = Text( final count = Text(
'${source.count(filter)}', '${source.count(filter)}',
style: const TextStyle(color: FilterGridPage.detailColor), style: TextStyle(color: FilterGridPage.detailColor),
); );
return filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album) return filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)
? Row( ? Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
AIcons.removableStorage, AIcons.removableStorage,
size: 16, size: 16,
color: FilterGridPage.detailColor, color: FilterGridPage.detailColor,
), ),
const SizedBox(width: 8), SizedBox(width: 8),
count, count,
], ],
) )

View file

@ -1,4 +1,5 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:typed_data';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
@ -36,8 +37,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
length: 2, length: 2,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Debug'), title: Text('Debug'),
bottom: const TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(text: 'DB'), Tab(text: 'DB'),
Tab(text: 'Content Resolver'), Tab(text: 'Content Resolver'),
@ -59,13 +60,13 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Widget _buildDbTabView() { Widget _buildDbTabView() {
final catalog = widget.entry.catalogMetadata; final catalog = widget.entry.catalogMetadata;
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
FutureBuilder( FutureBuilder<DateMetadata>(
future: _dbDateLoader, future: _dbDateLoader,
builder: (context, AsyncSnapshot<DateMetadata> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data; final data = snapshot.data;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -79,12 +80,12 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
); );
}, },
), ),
const SizedBox(height: 16), SizedBox(height: 16),
FutureBuilder( FutureBuilder<CatalogMetadata>(
future: _dbMetadataLoader, future: _dbMetadataLoader,
builder: (context, AsyncSnapshot<CatalogMetadata> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data; final data = snapshot.data;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -105,12 +106,12 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
); );
}, },
), ),
const SizedBox(height: 16), SizedBox(height: 16),
FutureBuilder( FutureBuilder<AddressDetails>(
future: _dbAddressLoader, future: _dbAddressLoader,
builder: (context, AsyncSnapshot<AddressDetails> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = snapshot.data; final data = snapshot.data;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -128,7 +129,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
); );
}, },
), ),
const Divider(), Divider(),
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'), Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
if (catalog != null) if (catalog != null)
InfoRowGroup({ InfoRowGroup({
@ -152,13 +153,13 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Widget _buildContentResolverTabView() { Widget _buildContentResolverTabView() {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
children: [ children: [
FutureBuilder( FutureBuilder<Map>(
future: _contentResolverMetadataLoader, future: _contentResolverMetadataLoader,
builder: (context, AsyncSnapshot<Map> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) { final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString(); final key = k.toString();
var value = v?.toString() ?? 'null'; var value = v?.toString() ?? 'null';
@ -168,6 +169,9 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
} }
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
} }
if (key == 'xmp' && v != null && v is Uint8List) {
value = String.fromCharCodes(v);
}
return MapEntry(key, value); return MapEntry(key, value);
})); }));
return InfoRowGroup(data); return InfoRowGroup(data);

View file

@ -87,7 +87,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
// no bounce at the bottom, to avoid video controller displacement // no bounce at the bottom, to avoid video controller displacement
curve: Curves.easeOutQuad, curve: Curves.easeOutQuad,
); );
_bottomOverlayOffset = Tween(begin: const Offset(0, 1), end: const Offset(0, 0)).animate(CurvedAnimation( _bottomOverlayOffset = Tween(begin: Offset(0, 1), end: Offset(0, 0)).animate(CurvedAnimation(
parent: _overlayAnimationController, parent: _overlayAnimationController,
curve: Curves.easeOutQuad, curve: Curves.easeOutQuad,
)); ));
@ -178,7 +178,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
final child = ValueListenableBuilder<ImageEntry>( final child = ValueListenableBuilder<ImageEntry>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, entry, child) { builder: (context, entry, child) {
if (entry == null) return const SizedBox.shrink(); if (entry == null) return SizedBox.shrink();
return FullscreenTopOverlay( return FullscreenTopOverlay(
entry: entry, entry: entry,
scale: _topOverlayScale, scale: _topOverlayScale,
@ -459,7 +459,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pages = [ final pages = [
// fake page for opacity transition between collection and fullscreen views // fake page for opacity transition between collection and fullscreen views
const SizedBox(), SizedBox(),
hasCollection hasCollection
? MultiImagePage( ? MultiImagePage(
collection: collection, collection: collection,
@ -494,7 +494,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
child: PageView( child: PageView(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
controller: widget.verticalPager, controller: widget.verticalPager,
physics: const PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()), physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
onPageChanged: (page) { onPageChanged: (page) {
widget.onVerticalPageChanged(page); widget.onVerticalPageChanged(page);
_infoPageVisibleNotifier.value = page == pages.length - 1; _infoPageVisibleNotifier.value = page == pages.length - 1;

View file

@ -39,7 +39,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
child: PageView.builder( child: PageView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
controller: widget.pageController, controller: widget.pageController,
physics: const PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()), physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
onPageChanged: widget.onPageChanged, onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];

View file

@ -41,7 +41,7 @@ class ImageView extends StatelessWidget {
entry: entry, entry: entry,
controller: videoController, controller: videoController,
) )
: const SizedBox(), : SizedBox(),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged, scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
@ -61,7 +61,7 @@ class ImageView extends StatelessWidget {
// if the image is already in the cache it will show the final image, otherwise the thumbnail // if the image is already in the cache it will show the final image, otherwise the thumbnail
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image // in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale` // appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
final loadingBuilder = (BuildContext context, ImageProvider imageProvider) { Widget loadingBuilder(BuildContext context, ImageProvider imageProvider) {
return Center( return Center(
child: AspectRatio( child: AspectRatio(
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
@ -72,7 +72,7 @@ class ImageView extends StatelessWidget {
), ),
), ),
); );
}; }
Widget child; Widget child;
if (entry.isSvg) { if (entry.isSvg) {
@ -107,7 +107,7 @@ class ImageView extends StatelessWidget {
context, context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider, imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
), ),
loadFailedChild: const EmptyContent( loadFailedChild: EmptyContent(
icon: AIcons.error, icon: AIcons.error,
text: 'Oops!', text: 'Oops!',
alignment: Alignment.center, alignment: Alignment.center,

View file

@ -53,7 +53,7 @@ class BasicSection extends StatelessWidget {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final album = entry.directory; final album = entry.directory;
final filters = [ final filters = [
if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO), if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
if (entry.isAnimated) MimeFilter(MimeFilter.animated), if (entry.isAnimated) MimeFilter(MimeFilter.animated),
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)), ...tags.map((tag) => TagFilter(tag)),
@ -65,9 +65,9 @@ class BasicSection extends StatelessWidget {
...filters, ...filters,
if (entry.isFavourite) FavouriteFilter(), if (entry.isFavourite) FavouriteFilter(),
]..sort(); ]..sort();
if (effectiveFilters.isEmpty) return const SizedBox.shrink(); if (effectiveFilters.isEmpty) return SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,

View file

@ -40,11 +40,11 @@ class InfoPageState extends State<InfoPage> {
final appBar = SliverAppBar( final appBar = SliverAppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(AIcons.goUp), icon: Icon(AIcons.goUp),
onPressed: _goToImage, onPressed: _goToImage,
tooltip: 'Back to image', tooltip: 'Back to image',
), ),
title: const Text('Info'), title: Text('Info'),
floating: true, floating: true,
); );
@ -60,7 +60,7 @@ class InfoPageState extends State<InfoPage> {
final mqViewInsetsBottom = mq.item2; final mqViewInsetsBottom = mq.item2;
final split = mqWidth > 400; final split = mqWidth > 400;
return ValueListenableBuilder( return ValueListenableBuilder<ImageEntry>(
valueListenable: widget.entryNotifier, valueListenable: widget.entryNotifier,
builder: (context, entry, child) { builder: (context, entry, child) {
final locationAtTop = split && entry.hasGps; final locationAtTop = split && entry.hasGps;
@ -77,7 +77,7 @@ class InfoPageState extends State<InfoPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)), Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)),
const SizedBox(width: 8), SizedBox(width: 8),
Expanded(child: locationSection), Expanded(child: locationSection),
], ],
), ),
@ -100,7 +100,7 @@ class InfoPageState extends State<InfoPage> {
slivers: [ slivers: [
appBar, appBar,
SliverPadding( SliverPadding(
padding: horizontalPadding + const EdgeInsets.only(top: 8), padding: horizontalPadding + EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver, sliver: basicAndLocationSliver,
), ),
SliverPadding( SliverPadding(
@ -165,7 +165,7 @@ class SectionRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const dim = 32.0; const dim = 32.0;
final buildDivider = () => const SizedBox( Widget buildDivider() => SizedBox(
width: dim, width: dim,
child: Divider( child: Divider(
thickness: AvesFilterChip.outlineWidth, thickness: AvesFilterChip.outlineWidth,
@ -177,7 +177,7 @@ class SectionRow extends StatelessWidget {
children: [ children: [
buildDivider(), buildDivider(),
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Icon( child: Icon(
icon, icon,
size: dim, size: dim,
@ -196,7 +196,7 @@ class InfoRowGroup extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (keyValues.isEmpty) return const SizedBox.shrink(); if (keyValues.isEmpty) return SizedBox.shrink();
final lastKey = keyValues.keys.last; final lastKey = keyValues.keys.last;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -206,13 +206,13 @@ class InfoRowGroup extends StatelessWidget {
children: keyValues.entries children: keyValues.entries
.expand( .expand(
(kv) => [ (kv) => [
TextSpan(text: '${kv.key} ', style: const TextStyle(color: Colors.white70, height: 1.7)), TextSpan(text: '${kv.key} ', style: TextStyle(color: Colors.white70, height: 1.7)),
TextSpan(text: '${kv.value}${kv.key == lastKey ? '' : '\n'}'), TextSpan(text: '${kv.value}${kv.key == lastKey ? '' : '\n'}'),
], ],
) )
.toList(), .toList(),
), ),
style: const TextStyle(fontFamily: 'Concourse'), style: TextStyle(fontFamily: 'Concourse'),
), ),
], ],
); );

View file

@ -1,7 +1,7 @@
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
@ -90,7 +90,7 @@ class _LocationSectionState extends State<LocationSection> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.showTitle) if (widget.showTitle)
const Padding( Padding(
padding: EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8),
child: SectionRow(AIcons.location), child: SectionRow(AIcons.location),
), ),
@ -105,12 +105,12 @@ class _LocationSectionState extends State<LocationSection> {
), ),
if (location.isNotEmpty) if (location.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: InfoRowGroup({'Address': location}), child: InfoRowGroup({'Address': location}),
), ),
if (filters.isNotEmpty) if (filters.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@ -126,7 +126,7 @@ class _LocationSectionState extends State<LocationSection> {
); );
} else { } else {
_loadedUri = null; _loadedUri = null;
return const SizedBox.shrink(); return SizedBox.shrink();
} }
} }
@ -175,7 +175,7 @@ class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
// and triggering by mistake a move to the image page above // and triggering by mistake a move to the image page above
}, },
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(16), Radius.circular(16),
), ),
child: Container( child: Container(
@ -208,24 +208,24 @@ class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
), ),
), ),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
TooltipTheme( TooltipTheme(
data: TooltipTheme.of(context).copyWith( data: TooltipTheme.of(context).copyWith(
preferBelow: false, preferBelow: false,
), ),
child: Column(children: [ child: Column(children: [
IconButton( IconButton(
icon: const Icon(AIcons.zoomIn), icon: Icon(AIcons.zoomIn),
onPressed: _controller == null ? null : () => _zoomBy(1), onPressed: _controller == null ? null : () => _zoomBy(1),
tooltip: 'Zoom in', tooltip: 'Zoom in',
), ),
IconButton( IconButton(
icon: const Icon(AIcons.zoomOut), icon: Icon(AIcons.zoomOut),
onPressed: _controller == null ? null : () => _zoomBy(-1), onPressed: _controller == null ? null : () => _zoomBy(-1),
tooltip: 'Zoom out', tooltip: 'Zoom out',
), ),
IconButton( IconButton(
icon: const Icon(AIcons.openInNew), icon: Icon(AIcons.openInNew),
onPressed: () => AndroidAppService.openMap(widget.geoUri), onPressed: () => AndroidAppService.openMap(widget.geoUri),
tooltip: 'Show on map...', tooltip: 'Show on map...',
), ),

View file

@ -65,7 +65,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
if (_metadata.isEmpty) return const SliverToBoxAdapter(child: SizedBox.shrink()); if (_metadata.isEmpty) return SliverToBoxAdapter(child: SizedBox.shrink());
final directoriesWithoutTitle = _metadata.where((dir) => dir.name.isEmpty).toList(); final directoriesWithoutTitle = _metadata.where((dir) => dir.name.isEmpty).toList();
final directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty).toList(); final directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty).toList();
@ -74,7 +74,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) { (context, index) {
if (index == 0) { if (index == 0) {
return const SectionRow(AIcons.info); return SectionRow(AIcons.info);
} }
if (index < untitledDirectoryCount + 1) { if (index < untitledDirectoryCount + 1) {
final dir = directoriesWithoutTitle[index - 1]; final dir = directoriesWithoutTitle[index - 1];
@ -91,10 +91,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
expandedNotifier: _expandedDirectoryNotifier, expandedNotifier: _expandedDirectoryNotifier,
title: _DirectoryTitle(dir.name), title: _DirectoryTitle(dir.name),
children: [ children: [
const Divider(thickness: 1.0, height: 1.0), Divider(thickness: 1.0, height: 1.0),
Container( Container(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
padding: const EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: InfoRowGroup(dir.tags), child: InfoRowGroup(dir.tags),
), ),
], ],
@ -150,10 +150,10 @@ class _DirectoryTitle extends StatelessWidget {
decoration: HighlightDecoration( decoration: HighlightDecoration(
color: stringToColor(name), color: stringToColor(name),
), ),
margin: const EdgeInsets.symmetric(vertical: 4.0), margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text( child: Text(
name, name,
style: const TextStyle( style: TextStyle(
shadows: [ shadows: [
Shadow( Shadow(
color: Colors.black, color: Colors.black,

View file

@ -82,15 +82,15 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
return Container( return Container(
color: FullscreenOverlay.backgroundColor, color: FullscreenOverlay.backgroundColor,
padding: viewInsets + viewPadding.copyWith(top: 0), padding: viewInsets + viewPadding.copyWith(top: 0),
child: FutureBuilder( child: FutureBuilder<OverlayMetadata>(
future: _detailLoader, future: _detailLoader,
builder: (futureContext, AsyncSnapshot<OverlayMetadata> snapshot) { builder: (futureContext, snapshot) {
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
_lastDetails = snapshot.data; _lastDetails = snapshot.data;
_lastEntry = entry; _lastEntry = entry;
} }
return _lastEntry == null return _lastEntry == null
? const SizedBox.shrink() ? SizedBox.shrink()
: Padding( : Padding(
// keep padding inside `FutureBuilder` so that overlay takes no space until data is ready // keep padding inside `FutureBuilder` so that overlay takes no space until data is ready
padding: innerPadding, padding: innerPadding,
@ -134,7 +134,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DefaultTextStyle( return DefaultTextStyle(
style: Theme.of(context).textTheme.bodyText2.copyWith( style: Theme.of(context).textTheme.bodyText2.copyWith(
shadows: const [ shadows: [
Shadow( Shadow(
color: Colors.black87, color: Colors.black87,
offset: Offset(0.5, 1.0), offset: Offset(0.5, 1.0),
@ -163,12 +163,12 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle), if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
if (entry.hasGps) if (entry.hasGps)
Container( Container(
padding: const EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
child: _LocationRow(entry: entry), child: _LocationRow(entry: entry),
), ),
if (twoColumns) if (twoColumns)
Padding( Padding(
padding: const EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
child: Row( child: Row(
children: [ children: [
Container(width: subRowWidth, child: _DateRow(entry)), Container(width: subRowWidth, child: _DateRow(entry)),
@ -178,13 +178,13 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
) )
else ...[ else ...[
Container( Container(
padding: const EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth, width: subRowWidth,
child: _DateRow(entry), child: _DateRow(entry),
), ),
if (hasShootingDetails) if (hasShootingDetails)
Container( Container(
padding: const EdgeInsets.only(top: _interRowPadding), padding: EdgeInsets.only(top: _interRowPadding),
width: subRowWidth, width: subRowWidth,
child: _ShootingRow(details), child: _ShootingRow(details),
), ),
@ -216,8 +216,8 @@ class _LocationRow extends AnimatedWidget {
} }
return Row( return Row(
children: [ children: [
const Icon(AIcons.location, size: _iconSize), Icon(AIcons.location, size: _iconSize),
const SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
], ],
); );
@ -236,8 +236,8 @@ class _DateRow extends StatelessWidget {
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row( return Row(
children: [ children: [
const Icon(AIcons.date, size: _iconSize), Icon(AIcons.date, size: _iconSize),
const SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)), if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)),
], ],
@ -254,8 +254,8 @@ class _ShootingRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
const Icon(AIcons.shooting, size: _iconSize), Icon(AIcons.shooting, size: _iconSize),
const SizedBox(width: _iconPadding), SizedBox(width: _iconPadding),
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)),
Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)),

View file

@ -40,7 +40,7 @@ class FullscreenTopOverlay extends StatelessWidget {
return SafeArea( return SafeArea(
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero), minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
child: Padding( child: Padding(
padding: const EdgeInsets.all(padding), padding: EdgeInsets.all(padding),
child: Selector<MediaQueryData, Tuple2<double, Orientation>>( child: Selector<MediaQueryData, Tuple2<double, Orientation>>(
selector: (c, mq) => Tuple2(mq.size.width, mq.orientation), selector: (c, mq) => Tuple2(mq.size.width, mq.orientation),
builder: (c, mq, child) { builder: (c, mq, child) {
@ -126,19 +126,19 @@ class _TopOverlayRow extends StatelessWidget {
children: [ children: [
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: ModalRoute.of(context)?.canPop ?? true ? const BackButton() : const CloseButton(), child: ModalRoute.of(context)?.canPop ?? true ? BackButton() : CloseButton(),
), ),
const Spacer(), Spacer(),
...quickActions.map(_buildOverlayButton), ...quickActions.map(_buildOverlayButton),
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: PopupMenuButton<EntryAction>( child: PopupMenuButton<EntryAction>(
itemBuilder: (context) => [ itemBuilder: (context) => [
...inAppActions.map(_buildPopupMenuItem), ...inAppActions.map(_buildPopupMenuItem),
const PopupMenuDivider(), PopupMenuDivider(),
...externalAppActions.map(_buildPopupMenuItem), ...externalAppActions.map(_buildPopupMenuItem),
if (kDebugMode) ...[ if (kDebugMode) ...[
const PopupMenuDivider(), PopupMenuDivider(),
_buildPopupMenuItem(EntryAction.debug), _buildPopupMenuItem(EntryAction.debug),
] ]
], ],
@ -151,7 +151,7 @@ class _TopOverlayRow extends StatelessWidget {
Widget _buildOverlayButton(EntryAction action) { Widget _buildOverlayButton(EntryAction action) {
Widget child; Widget child;
final onPressed = () => onActionSelected?.call(action); void onPressed() => onActionSelected?.call(action);
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
@ -181,13 +181,13 @@ class _TopOverlayRow extends StatelessWidget {
} }
return child != null return child != null
? Padding( ? Padding(
padding: const EdgeInsetsDirectional.only(end: padding), padding: EdgeInsetsDirectional.only(end: padding),
child: OverlayButton( child: OverlayButton(
scale: scale, scale: scale,
child: child, child: child,
), ),
) )
: const SizedBox.shrink(); : SizedBox.shrink();
} }
PopupMenuEntry<EntryAction> _buildPopupMenuItem(EntryAction action) { PopupMenuEntry<EntryAction> _buildPopupMenuItem(EntryAction action) {
@ -269,11 +269,11 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
builder: (context, isFavourite, child) { builder: (context, isFavourite, child) {
if (widget.isMenuItem) { if (widget.isMenuItem) {
return isFavourite return isFavourite
? const MenuRow( ? MenuRow(
text: 'Remove from favourites', text: 'Remove from favourites',
icon: AIcons.favouriteActive, icon: AIcons.favouriteActive,
) )
: const MenuRow( : MenuRow(
text: 'Add to favourites', text: 'Add to favourites',
icon: AIcons.favourite, icon: AIcons.favourite,
); );
@ -288,7 +288,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
), ),
Sweeper( Sweeper(
key: ValueKey(widget.entry), key: ValueKey(widget.entry),
builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent), builder: (context) => Icon(AIcons.favourite, color: Colors.redAccent),
toggledNotifier: isFavouriteNotifier, toggledNotifier: isFavouriteNotifier,
), ),
], ],

View file

@ -98,14 +98,14 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mq = context.select((MediaQueryData mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); final mq = context.select<MediaQueryData, Tuple3<double, EdgeInsets, EdgeInsets>>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding));
final mqWidth = mq.item1; final mqWidth = mq.item1;
final mqViewInsets = mq.item2; final mqViewInsets = mq.item2;
final mqViewPadding = mq.item3; final mqViewPadding = mq.item3;
final viewInsets = widget.viewInsets ?? mqViewInsets; final viewInsets = widget.viewInsets ?? mqViewInsets;
final viewPadding = widget.viewPadding ?? mqViewPadding; final viewPadding = widget.viewPadding ?? mqViewPadding;
final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8.0); final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + EdgeInsets.symmetric(horizontal: 8.0);
return Padding( return Padding(
padding: safePadding, padding: safePadding,
@ -127,7 +127,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: IconButton( child: IconButton(
icon: const Icon(AIcons.openInNew), icon: Icon(AIcons.openInNew),
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
tooltip: 'Open', tooltip: 'Open',
), ),
@ -137,7 +137,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
Expanded( Expanded(
child: _buildProgressBar(), child: _buildProgressBar(),
), ),
const SizedBox(width: 8), SizedBox(width: 8),
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: IconButton( child: IconButton(
@ -164,25 +164,25 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
child: BlurredRRect( child: BlurredRRect(
borderRadius: progressBarBorderRadius, borderRadius: progressBarBorderRadius,
child: GestureDetector( child: GestureDetector(
onTapDown: (TapDownDetails details) { onTapDown: (details) {
_seekFromTap(details.globalPosition); _seekFromTap(details.globalPosition);
}, },
onHorizontalDragStart: (DragStartDetails details) { onHorizontalDragStart: (details) {
_playingOnDragStart = isPlaying; _playingOnDragStart = isPlaying;
if (_playingOnDragStart) controller.pause(); if (_playingOnDragStart) controller.pause();
}, },
onHorizontalDragUpdate: (DragUpdateDetails details) { onHorizontalDragUpdate: (details) {
_seekFromTap(details.globalPosition); _seekFromTap(details.globalPosition);
}, },
onHorizontalDragEnd: (DragEndDetails details) { onHorizontalDragEnd: (details) {
if (_playingOnDragStart) controller.play(); if (_playingOnDragStart) controller.play();
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16) + const EdgeInsets.only(bottom: 16), padding: EdgeInsets.symmetric(vertical: 4, horizontal: 16) + EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: FullscreenOverlay.backgroundColor, color: FullscreenOverlay.backgroundColor,
border: FullscreenOverlay.buildBorder(context), border: FullscreenOverlay.buildBorder(context),
borderRadius: const BorderRadius.all( borderRadius: BorderRadius.all(
Radius.circular(progressBarBorderRadius), Radius.circular(progressBarBorderRadius),
), ),
), ),
@ -198,7 +198,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
final position = videoInfo.currentPosition?.floor() ?? 0; final position = videoInfo.currentPosition?.floor() ?? 0;
return Text(formatDuration(Duration(seconds: position))); return Text(formatDuration(Duration(seconds: position)));
}), }),
const Spacer(), Spacer(),
Text(entry.durationText), Text(entry.durationText),
], ],
), ),

View file

@ -60,7 +60,7 @@ class AvesVideoState extends State<AvesVideo> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (controller == null) return const SizedBox(); if (controller == null) return SizedBox();
return StreamBuilder<IjkStatus>( return StreamBuilder<IjkStatus>(
stream: widget.controller.ijkStatusStream, stream: widget.controller.ijkStatusStream,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -68,8 +68,8 @@ class AvesVideoState extends State<AvesVideo> {
return isPlayable(status) return isPlayable(status)
? IjkPlayer( ? IjkPlayer(
mediaController: controller, mediaController: controller,
controllerWidgetBuilder: (controller) => const SizedBox.shrink(), controllerWidgetBuilder: (controller) => SizedBox.shrink(),
statusWidgetBuilder: (context, controller, status) => const SizedBox.shrink(), statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
textureBuilder: (context, controller, info) { textureBuilder: (context, controller, info) {
var id = controller.textureId; var id = controller.textureId;
var child = id != null var child = id != null

View file

@ -68,7 +68,7 @@ class _HomePageState extends State<HomePage> {
// TODO TLAD apply pick mimetype(s) // TODO TLAD apply pick mimetype(s)
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
String pickMimeTypes = intentData['mimeType']; String pickMimeTypes = intentData['mimeType'];
debugPrint('pick mimeType=' + pickMimeTypes); debugPrint('pick mimeType=$pickMimeTypes');
break; break;
} }
} }
@ -92,11 +92,11 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder( return FutureBuilder<void>(
future: _appSetup, future: _appSetup,
builder: (context, AsyncSnapshot<void> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) return const Icon(AIcons.error); if (snapshot.hasError) return Icon(AIcons.error);
if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); if (snapshot.connectionState != ConnectionState.done) return Scaffold();
if (AvesApp.mode == AppMode.view) { if (AvesApp.mode == AppMode.view) {
return SingleFullscreenPage(entry: _viewerEntry); return SingleFullscreenPage(entry: _viewerEntry);
} }
@ -107,7 +107,7 @@ class _HomePageState extends State<HomePage> {
sortFactor: settings.collectionSortFactor, sortFactor: settings.collectionSortFactor,
)); ));
} }
return const SizedBox.shrink(); return SizedBox.shrink();
}); });
} }
} }

View file

@ -36,7 +36,7 @@ class FilterTable extends StatelessWidget {
final lineHeight = 16 * textScaleFactor; final lineHeight = 16 * textScaleFactor;
return Padding( return Padding(
padding: const EdgeInsetsDirectional.only(start: AvesFilterChip.outlineWidth / 2 + 6, end: 8), padding: EdgeInsetsDirectional.only(start: AvesFilterChip.outlineWidth / 2 + 6, end: 8),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth; final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth;
@ -49,7 +49,7 @@ class FilterTable extends StatelessWidget {
return TableRow( return TableRow(
children: [ children: [
Container( Container(
padding: const EdgeInsets.only(bottom: 8), padding: EdgeInsets.only(bottom: 8),
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip( child: AvesFilterChip(
filter: filter, filter: filter,
@ -67,14 +67,14 @@ class FilterTable extends StatelessWidget {
center: Text(NumberFormat.percentPattern().format(percent)), center: Text(NumberFormat.percentPattern().format(percent)),
), ),
Text( Text(
'${count}', '$count',
style: const TextStyle(color: Colors.white70), style: TextStyle(color: Colors.white70),
textAlign: TextAlign.end, textAlign: TextAlign.end,
), ),
], ],
); );
}).toList(), }).toList(),
columnWidths: const { columnWidths: {
0: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(chipWidth)), 0: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(chipWidth)),
2: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(countWidth)), 2: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(countWidth)),
}, },

View file

@ -1,10 +1,10 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.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/color_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/empty.dart';
@ -49,12 +49,12 @@ class StatsPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child; Widget child;
if (collection.isEmpty) { if (collection.isEmpty) {
child = const EmptyContent( child = EmptyContent(
icon: AIcons.image, icon: AIcons.image,
text: 'No images', text: 'No images',
); );
} else { } else {
final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map<String, int>((k, v) => MapEntry(k, v.length)); final byMimeTypes = groupBy<ImageEntry, String>(entries, (entry) => entry.mimeType).map<String, int>((k, v) => MapEntry(k, v.length));
final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image/'))); final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image/')));
final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/'))); final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
final mimeDonuts = Wrap( final mimeDonuts = Wrap(
@ -71,7 +71,7 @@ class StatsPage extends StatelessWidget {
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final lineHeight = 16 * textScaleFactor; final lineHeight = 16 * textScaleFactor;
final locationIndicator = Padding( final locationIndicator = Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
LinearPercentIndicator( LinearPercentIndicator(
@ -80,12 +80,12 @@ class StatsPage extends StatelessWidget {
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor, progressColor: Theme.of(context).accentColor,
animation: true, animation: true,
leading: const Icon(AIcons.location), leading: Icon(AIcons.location),
// right padding to match leading, so that inside label is aligned with outside label below // right padding to match leading, so that inside label is aligned with outside label below
padding: EdgeInsets.symmetric(horizontal: lineHeight) + const EdgeInsets.only(right: 24), padding: EdgeInsets.symmetric(horizontal: lineHeight) + EdgeInsets.only(right: 24),
center: Text(NumberFormat.percentPattern().format(withGpsPercent)), center: Text(NumberFormat.percentPattern().format(withGpsPercent)),
), ),
const SizedBox(height: 8), SizedBox(height: 8),
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'), Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
], ],
), ),
@ -103,7 +103,7 @@ class StatsPage extends StatelessWidget {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Stats'), title: Text('Stats'),
), ),
body: SafeArea( body: SafeArea(
child: child, child: child,
@ -118,9 +118,9 @@ class StatsPage extends StatelessWidget {
} }
Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map<String, num> byMimeTypes) { Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map<String, num> byMimeTypes) {
if (byMimeTypes.isEmpty) return const SizedBox.shrink(); if (byMimeTypes.isEmpty) return SizedBox.shrink();
final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); final sum = byMimeTypes.values.fold<int>(0, (prev, v) => prev + v);
final seriesData = byMimeTypes.entries.map((kv) => StringNumDatum(_cleanMime(kv.key), kv.value)).toList(); final seriesData = byMimeTypes.entries.map((kv) => StringNumDatum(_cleanMime(kv.key), kv.value)).toList();
seriesData.sort((kv1, kv2) { seriesData.sort((kv1, kv2) {
@ -158,7 +158,7 @@ class StatsPage extends StatelessWidget {
), ),
Center( Center(
child: Text( child: Text(
'${sum}\n${label(sum)}', '$sum\n${label(sum)}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@ -177,12 +177,12 @@ class StatsPage extends StatelessWidget {
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Padding( child: Padding(
padding: const EdgeInsetsDirectional.only(end: 8), padding: EdgeInsetsDirectional.only(end: 8),
child: Icon(AIcons.disc, color: stringToColor(kv.key)), child: Icon(AIcons.disc, color: stringToColor(kv.key)),
), ),
), ),
TextSpan(text: '${kv.key} '), TextSpan(text: '${kv.key} '),
TextSpan(text: '${kv.value}', style: const TextStyle(color: Colors.white70)), TextSpan(text: '${kv.value}', style: TextStyle(color: Colors.white70)),
], ],
), ),
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
@ -217,7 +217,7 @@ class StatsPage extends StatelessWidget {
return [ return [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.all(16),
child: Text( child: Text(
title, title,
style: Constants.titleTextStyle, style: Constants.titleTextStyle,

View file

@ -32,11 +32,11 @@ class _WelcomePageState extends State<WelcomePage> {
body: SafeArea( body: SafeArea(
child: Container( child: Container(
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: FutureBuilder( child: FutureBuilder<String>(
future: _termsLoader, future: _termsLoader,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) { builder: (context, snapshot) {
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final terms = snapshot.data; final terms = snapshot.data;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -73,21 +73,21 @@ class _WelcomePageState extends State<WelcomePage> {
return [ return [
...(MediaQuery.of(context).orientation == Orientation.portrait ...(MediaQuery.of(context).orientation == Orientation.portrait
? [ ? [
const AvesLogo(size: 64), AvesLogo(size: 64),
const SizedBox(height: 16), SizedBox(height: 16),
message, message,
] ]
: [ : [
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const AvesLogo(size: 48), AvesLogo(size: 48),
const SizedBox(width: 16), SizedBox(width: 16),
message, message,
], ],
) )
]), ]),
const SizedBox(height: 16), SizedBox(height: 16),
]; ];
} }
@ -98,14 +98,14 @@ class _WelcomePageState extends State<WelcomePage> {
text: 'I agree to the terms and conditions', text: 'I agree to the terms and conditions',
); );
final button = RaisedButton( final button = RaisedButton(
child: const Text('Continue'), child: Text('Continue'),
onPressed: _hasAcceptedTerms onPressed: _hasAcceptedTerms
? () { ? () {
settings.hasAcceptedTerms = true; settings.hasAcceptedTerms = true;
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => const HomePage(), builder: (context) => HomePage(),
), ),
(route) => false, (route) => false,
); );
@ -118,11 +118,11 @@ class _WelcomePageState extends State<WelcomePage> {
button, button,
] ]
: [ : [
const SizedBox(height: 16), SizedBox(height: 16),
Row( Row(
children: [ children: [
checkbox, checkbox,
const Spacer(), Spacer(),
button, button,
], ],
), ),
@ -135,7 +135,7 @@ class _WelcomePageState extends State<WelcomePage> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
color: Colors.white10, color: Colors.white10,
), ),
constraints: const BoxConstraints(maxWidth: 460), constraints: BoxConstraints(maxWidth: 460),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Markdown( child: Markdown(

View file

@ -28,7 +28,7 @@ packages:
name: barcode name: barcode
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.13.0" version: "1.14.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -103,6 +103,13 @@ packages:
url: "git://github.com/deckerst/expansion_tile_card.git" url: "git://github.com/deckerst/expansion_tile_card.git"
source: git source: git
version: "1.0.3" version: "1.0.3"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.1"
firebase_crashlytics: firebase_crashlytics:
dependency: "direct main" dependency: "direct main"
description: description:
@ -295,6 +302,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.4" version: "0.1.4"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
pdf: pdf:
dependency: "direct main" dependency: "direct main"
description: description:
@ -346,6 +367,13 @@ packages:
url: "git://github.com/deckerst/photo_view.git" url: "git://github.com/deckerst/photo_view.git"
source: git source: git
version: "0.9.2" version: "0.9.2"
platform:
dependency: transitive
description:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
platform_detect: platform_detect:
dependency: transitive dependency: transitive
description: description:
@ -367,13 +395,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.5.0" version: "3.5.0"
process:
dependency: transitive
description:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.13"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.1.3" version: "4.3.1"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -408,7 +443,14 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.7+3" version: "0.5.8"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.2+1"
shared_preferences_macos: shared_preferences_macos:
dependency: transitive dependency: transitive
description: description:
@ -497,7 +539,7 @@ packages:
name: synchronized name: synchronized
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.2.0+1" version: "2.2.0+2"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -532,7 +574,14 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.4.11" version: "5.5.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+1"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
@ -575,6 +624,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.8" version: "2.0.8"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
xml: xml:
dependency: transitive dependency: transitive
description: description:

View file

@ -11,7 +11,7 @@ description: A new Flutter application.
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.10+11 version: 1.1.0+12
# video_player (as of v0.10.8+2, backed by ExoPlayer): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)

8
test/widget_test.dart Normal file
View file

@ -0,0 +1,8 @@
import 'package:aves/main.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('TODO TLAD', (tester) async {
await tester.pumpWidget(AvesApp());
});
}