Merge branch 'develop'
This commit is contained in:
commit
ce0808fddf
84 changed files with 1023 additions and 814 deletions
10
.github/workflows/main.yml
vendored
10
.github/workflows/main.yml
vendored
|
@ -1,4 +1,4 @@
|
|||
name: Release an APK and an App Bundle on tagging
|
||||
name: Release on tag
|
||||
|
||||
on:
|
||||
push:
|
||||
|
@ -33,9 +33,11 @@ jobs:
|
|||
working-directory: ${{ github.workspace }}/scripts
|
||||
run: ./update_flutter_version.sh
|
||||
|
||||
# `flutter test` fails if test directory is missing
|
||||
#- name: Run the unit tests.
|
||||
# run: flutter test
|
||||
- name: Static analysis.
|
||||
run: flutter analyze
|
||||
|
||||
- name: Unit tests.
|
||||
run: flutter test
|
||||
|
||||
- name: Build signed artifacts.
|
||||
# `KEY_JKS` should contain the result of:
|
||||
|
|
16
README.md
16
README.md
|
@ -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.
|
||||
|
||||
<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
|
||||
|
||||
- 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
|
||||
- statistics
|
||||
- handle intents to view or pick images
|
||||
- support Android API 24 ~ 29 (Nougat ~ Android 10)
|
||||
- support Android API 24 ~ 30 (Nougat ~ R)
|
||||
|
||||
## 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: double tap and drag image to zoom in/out (aka quick scale, one finger zoom)
|
||||
- support: burst groups
|
||||
- support: Android R
|
||||
- subsampling/tiling
|
||||
|
||||
## 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.
|
||||
|
||||
[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
|
||||
|
|
|
@ -4,9 +4,30 @@ analyzer:
|
|||
exclude:
|
||||
- lib/generated_plugin_registrant.dart
|
||||
|
||||
# strong-mode:
|
||||
# implicit-casts: false
|
||||
# implicit-dynamic: false
|
||||
|
||||
linter:
|
||||
rules:
|
||||
- always_declare_return_types
|
||||
- prefer_const_constructors
|
||||
- prefer_const_constructors_in_immutables
|
||||
- prefer_const_declarations
|
||||
# from 'effective dart', excluded
|
||||
avoid_function_literals_in_foreach_calls: false # benefit?
|
||||
lines_longer_than_80_chars: false # nope
|
||||
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
|
||||
|
|
|
@ -41,7 +41,7 @@ if (keystorePropertiesFile.exists()) {
|
|||
}
|
||||
|
||||
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 {
|
||||
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:
|
||||
// https://github.com/flutter/flutter/issues/58247
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 29 // same as compileSdkVersion
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']]
|
||||
|
|
|
@ -36,6 +36,14 @@
|
|||
-->
|
||||
<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
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
|
|
@ -100,7 +100,7 @@ public class MainActivity extends FlutterActivity {
|
|||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (requestCode == PermissionManager.VOLUME_ROOT_PERMISSION_REQUEST_CODE) {
|
||||
if (resultCode != RESULT_OK || data.getData() == null) {
|
||||
PermissionManager.onPermissionResult(this, requestCode, false, null);
|
||||
PermissionManager.onPermissionResult(requestCode, null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -113,7 +113,7 @@ public class MainActivity extends FlutterActivity {
|
|||
getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
|
||||
|
||||
// resume pending action
|
||||
PermissionManager.onPermissionResult(this, requestCode, true, treeUri);
|
||||
PermissionManager.onPermissionResult(requestCode, treeUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
|
@ -22,10 +22,10 @@ import io.flutter.plugin.common.MethodChannel;
|
|||
public class StorageHandler implements MethodChannel.MethodCallHandler {
|
||||
public static final String CHANNEL = "deckers.thibault/aves/storage";
|
||||
|
||||
private Activity activity;
|
||||
private Context context;
|
||||
|
||||
public StorageHandler(Activity activity) {
|
||||
this.activity = activity;
|
||||
public StorageHandler(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -42,12 +42,12 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
|
|||
result.success(volumes);
|
||||
break;
|
||||
}
|
||||
case "requireVolumeAccessDialog": {
|
||||
String path = call.argument("path");
|
||||
if (path == null) {
|
||||
result.success(true);
|
||||
case "getInaccessibleDirectories": {
|
||||
List<String> dirPaths = call.argument("dirPaths");
|
||||
if (dirPaths == null) {
|
||||
result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null);
|
||||
} else {
|
||||
result.success(PermissionManager.requireVolumeAccessDialog(activity, path));
|
||||
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -60,15 +60,15 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
|
|||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
private List<Map<String, Object>> getStorageVolumes() {
|
||||
List<Map<String, Object>> volumes = new ArrayList<>();
|
||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
for (String volumePath : StorageUtils.getVolumePaths(activity)) {
|
||||
for (String volumePath : StorageUtils.getVolumePaths(context)) {
|
||||
try {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null) {
|
||||
Map<String, Object> volumeMap = new HashMap<>();
|
||||
volumeMap.put("path", volumePath);
|
||||
volumeMap.put("description", volume.getDescription(activity));
|
||||
volumeMap.put("description", volume.getDescription(context));
|
||||
volumeMap.put("isPrimary", volume.isPrimary());
|
||||
volumeMap.put("isRemovable", volume.isRemovable());
|
||||
volumeMap.put("isEmulated", volume.isEmulated());
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.streams;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
@ -25,7 +25,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
|
||||
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
|
||||
|
||||
private Activity activity;
|
||||
private Context context;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private Map<String, Object> argMap;
|
||||
|
@ -33,8 +33,8 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
private String op;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ImageOpStreamHandler(Activity activity, Object arguments) {
|
||||
this.activity = activity;
|
||||
public ImageOpStreamHandler(Context context, Object arguments) {
|
||||
this.context = context;
|
||||
if (arguments instanceof Map) {
|
||||
argMap = (Map<String, Object>) arguments;
|
||||
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());
|
||||
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||
provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> fields) {
|
||||
success(fields);
|
||||
|
@ -138,7 +138,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
|||
put("uri", uriString);
|
||||
}};
|
||||
try {
|
||||
provider.delete(activity, path, uri).get();
|
||||
provider.delete(context, path, uri).get();
|
||||
result.put("success", true);
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.streams;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
|
@ -12,14 +12,14 @@ import io.flutter.plugin.common.EventChannel;
|
|||
public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
|
||||
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
|
||||
|
||||
private Activity activity;
|
||||
private Context context;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private Map<Integer, Integer> knownEntries;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public MediaStoreStreamHandler(Activity activity, Object arguments) {
|
||||
this.activity = activity;
|
||||
public MediaStoreStreamHandler(Context context, Object arguments) {
|
||||
this.context = context;
|
||||
if (arguments instanceof Map) {
|
||||
Map<String, Object> argMap = (Map<String, Object>) arguments;
|
||||
this.knownEntries = (Map<Integer, Integer>) argMap.get("knownEntries");
|
||||
|
@ -47,7 +47,7 @@ public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
|
|||
}
|
||||
|
||||
void fetchAll() {
|
||||
new MediaStoreImageProvider().fetchAll(activity, knownEntries, this::success); // 350ms
|
||||
new MediaStoreImageProvider().fetchAll(context, knownEntries, this::success); // 350ms
|
||||
endOfStream();
|
||||
}
|
||||
}
|
|
@ -17,14 +17,14 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
|
|||
private Activity activity;
|
||||
private EventChannel.EventSink eventSink;
|
||||
private Handler handler;
|
||||
private String volumePath;
|
||||
private String path;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public StorageAccessStreamHandler(Activity activity, Object arguments) {
|
||||
this.activity = activity;
|
||||
if (arguments instanceof Map) {
|
||||
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) {
|
||||
this.eventSink = eventSink;
|
||||
this.handler = new Handler(Looper.getMainLooper());
|
||||
Runnable onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, volumePath));
|
||||
Runnable onDenied = () -> success(false);
|
||||
PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied);
|
||||
Runnable onGranted = () -> success(true); // user gave access to a directory, with no guarantee that it matches the specified `path`
|
||||
Runnable onDenied = () -> success(false); // user cancelled
|
||||
PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.media.MediaMetadataRetriever;
|
|||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.drew.imaging.ImageMetadataReader;
|
||||
|
@ -53,7 +54,7 @@ public class 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.path = (String) map.get("path");
|
||||
this.sourceMimeType = (String) map.get("sourceMimeType");
|
||||
|
@ -121,7 +122,7 @@ public class SourceImageEntry {
|
|||
|
||||
// expects entry with: uri, mimeType
|
||||
// finds: width, height, orientation/rotation, date, title, duration
|
||||
public SourceImageEntry fillPreCatalogMetadata(Context context) {
|
||||
public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) {
|
||||
fillByMediaMetadataRetriever(context);
|
||||
if (hasSize() && (!isVideo() || hasDuration())) return this;
|
||||
fillByMetadataExtractor(context);
|
||||
|
@ -132,7 +133,7 @@ public class SourceImageEntry {
|
|||
|
||||
// expects entry with: uri, mimeType
|
||||
// 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);
|
||||
try {
|
||||
String width = null, height = null, rotation = null, durationMillis = null;
|
||||
|
@ -182,7 +183,7 @@ public class SourceImageEntry {
|
|||
|
||||
// expects entry with: uri, mimeType
|
||||
// finds: width, height, orientation, date
|
||||
private void fillByMetadataExtractor(Context context) {
|
||||
private void fillByMetadataExtractor(@NonNull Context context) {
|
||||
if (isSvg()) return;
|
||||
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
|
@ -244,7 +245,7 @@ public class SourceImageEntry {
|
|||
|
||||
// expects entry with: uri
|
||||
// finds: width, height
|
||||
private void fillByBitmapDecode(Context context) {
|
||||
private void fillByBitmapDecode(@NonNull Context context) {
|
||||
if (isSvg()) return;
|
||||
|
||||
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
|
||||
|
@ -260,7 +261,7 @@ public class SourceImageEntry {
|
|||
|
||||
// convenience method
|
||||
|
||||
private static Long toLong(Object o) {
|
||||
private static Long toLong(@Nullable Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Integer) return Long.valueOf((Integer) o);
|
||||
return (long) o;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package deckers.thibault.aves.model.provider;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
@ -9,8 +8,6 @@ import android.graphics.BitmapFactory;
|
|||
import android.graphics.Matrix;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
|
||||
|
@ -32,7 +29,6 @@ import java.util.Map;
|
|||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
||||
|
@ -52,15 +48,15 @@ public abstract class ImageProvider {
|
|||
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());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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) {
|
||||
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
|
||||
return;
|
||||
|
@ -74,13 +70,7 @@ public abstract class ImageProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
if (PermissionManager.requireVolumeAccessDialog(activity, oldPath)) {
|
||||
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);
|
||||
DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri);
|
||||
try {
|
||||
boolean renamed = df != null && df.renameTo(newFilename);
|
||||
if (!renamed) {
|
||||
|
@ -92,33 +82,27 @@ public abstract class ImageProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null);
|
||||
scanNewPath(activity, 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) {
|
||||
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;
|
||||
MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null);
|
||||
scanNewPath(context, newFile.getPath(), mimeType, callback);
|
||||
}
|
||||
|
||||
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.JPEG:
|
||||
rotateJpeg(activity, path, uri, clockwise, callback);
|
||||
rotateJpeg(context, path, uri, clockwise, callback);
|
||||
break;
|
||||
case MimeTypes.PNG:
|
||||
rotatePng(activity, path, uri, clockwise, callback);
|
||||
rotatePng(context, path, uri, clockwise, callback);
|
||||
break;
|
||||
default:
|
||||
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 DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri);
|
||||
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
|
||||
if (originalDocumentFile == null) {
|
||||
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
|
||||
return;
|
||||
|
@ -163,7 +147,7 @@ public abstract class ImageProvider {
|
|||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("orientationDegrees", orientationDegrees);
|
||||
|
||||
// ContentResolver contentResolver = activity.getContentResolver();
|
||||
// ContentResolver contentResolver = context.getContentResolver();
|
||||
// ContentValues values = new ContentValues();
|
||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
|
@ -178,17 +162,17 @@ public abstract class ImageProvider {
|
|||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
// 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 {
|
||||
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||
// 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 DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(activity, path, uri);
|
||||
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
|
||||
if (originalDocumentFile == null) {
|
||||
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
|
||||
return;
|
||||
|
@ -203,7 +187,7 @@ public abstract class ImageProvider {
|
|||
|
||||
Bitmap originalImage;
|
||||
try {
|
||||
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(activity, uri));
|
||||
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri));
|
||||
} catch (FileNotFoundException e) {
|
||||
callback.onFailure(e);
|
||||
return;
|
||||
|
@ -235,7 +219,7 @@ public abstract class ImageProvider {
|
|||
newFields.put("width", rotatedWidth);
|
||||
newFields.put("height", rotatedHeight);
|
||||
|
||||
// ContentResolver contentResolver = activity.getContentResolver();
|
||||
// ContentResolver contentResolver = context.getContentResolver();
|
||||
// ContentValues values = new ContentValues();
|
||||
// // from Android Q, media store update needs to be flagged IS_PENDING first
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
|
@ -250,15 +234,15 @@ public abstract class ImageProvider {
|
|||
// // TODO TLAD catch RecoverableSecurityException
|
||||
// int updatedRowCount = contentResolver.update(uri, values, null, null);
|
||||
// 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 {
|
||||
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
|
||||
// callback.onSuccess(newFields);
|
||||
// }
|
||||
}
|
||||
|
||||
protected void scanNewPath(final Activity activity, final String path, final String mimeType, final ImageOpCallback callback) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
|
||||
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
|
||||
Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri);
|
||||
|
||||
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
|
||||
String[] projection = {MediaStore.MediaColumns.TITLE};
|
||||
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.moveToNext()) {
|
||||
newFields.put("uri", contentUri.toString());
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
package deckers.thibault.aves.model.provider;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.provider.MediaStore;
|
||||
|
@ -35,7 +32,6 @@ import java.util.stream.Stream;
|
|||
import deckers.thibault.aves.model.AvesImageEntry;
|
||||
import deckers.thibault.aves.model.SourceImageEntry;
|
||||
import deckers.thibault.aves.utils.MimeTypes;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
||||
|
@ -213,26 +209,14 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
@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();
|
||||
|
||||
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
|
||||
// but it doesn't delete the file, even if the app has the permission
|
||||
try {
|
||||
DocumentFileCompat df = StorageUtils.getDocumentFile(activity, path, mediaUri);
|
||||
DocumentFileCompat df = StorageUtils.getDocumentFile(context, path, mediaUri);
|
||||
if (df != null && df.delete()) {
|
||||
future.set(null);
|
||||
} else {
|
||||
|
@ -245,7 +229,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
try {
|
||||
if (activity.getContentResolver().delete(mediaUri, null, null) > 0) {
|
||||
if (context.getContentResolver().delete(mediaUri, null, null) > 0) {
|
||||
future.set(null);
|
||||
} else {
|
||||
future.setException(new Exception("failed to delete row from content provider"));
|
||||
|
@ -257,11 +241,11 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
return future;
|
||||
}
|
||||
|
||||
private String getVolumeName(final Activity activity, String path) {
|
||||
private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) {
|
||||
String volumeName = "external";
|
||||
StorageManager sm = activity.getSystemService(StorageManager.class);
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
||||
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
|
||||
if (volume != null && !volume.isPrimary()) {
|
||||
String uuid = volume.getUuid();
|
||||
if (uuid != null) {
|
||||
|
@ -275,20 +259,14 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
|
||||
if (PermissionManager.requireVolumeAccessDialog(activity, 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);
|
||||
public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
|
||||
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir);
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
|
||||
return;
|
||||
}
|
||||
|
||||
MediaStoreMoveDestination destination = new MediaStoreMoveDestination(activity, destinationDir);
|
||||
MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir);
|
||||
if (destination.volumePath == null) {
|
||||
callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir));
|
||||
return;
|
||||
|
@ -303,14 +281,14 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
put("uri", sourceUri.toString());
|
||||
}};
|
||||
|
||||
// TODO TLAD check if there is any downside to use tree document files with scoped storage on API 30+
|
||||
// when testing scoped storage on API 29, it seems less constraining to use tree document files than to rely on the Media Store
|
||||
// on API 30 we cannot get access granted directly to a volume root from its document tree,
|
||||
// but it is still less constraining to use tree document files than to rely on the Media Store
|
||||
try {
|
||||
ListenableFuture<Map<String, Object>> newFieldsFuture;
|
||||
// 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 {
|
||||
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
|
||||
newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
|
||||
// }
|
||||
Map<String, Object> newFields = newFieldsFuture.get();
|
||||
result.put("success", true);
|
||||
|
@ -330,7 +308,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
// - 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
|
||||
@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) {
|
||||
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)
|
||||
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
|
||||
String volumeName = destination.volumeNameForMediaStore;
|
||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ?
|
||||
MediaStore.Video.Media.getContentUri(destination.volumeName) :
|
||||
MediaStore.Images.Media.getContentUri(destination.volumeName);
|
||||
Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues);
|
||||
MediaStore.Video.Media.getContentUri(volumeName) :
|
||||
MediaStore.Images.Media.getContentUri(volumeName);
|
||||
Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);
|
||||
if (destinationUri == null) {
|
||||
future.setException(new Exception("failed to insert row to content resolver"));
|
||||
} else {
|
||||
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri);
|
||||
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri);
|
||||
sourceFile.copyTo(destinationFile);
|
||||
|
||||
boolean deletedSource = false;
|
||||
if (!copy) {
|
||||
// delete original entry
|
||||
try {
|
||||
delete(activity, sourcePath, sourceUri).get();
|
||||
delete(context, sourcePath, sourceUri).get();
|
||||
deletedSource = true;
|
||||
} catch (ExecutionException | InterruptedException 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 need to scan the file to get the Media Store content URI
|
||||
// - 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();
|
||||
|
||||
try {
|
||||
|
@ -396,12 +375,12 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
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.copyDocument()` yields "Unsupported call: android:copyDocument"
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
// delete original entry
|
||||
try {
|
||||
delete(activity, sourcePath, sourceUri).get();
|
||||
delete(context, sourcePath, sourceUri).get();
|
||||
deletedSource = true;
|
||||
} catch (ExecutionException | InterruptedException 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;
|
||||
scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
|
||||
scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> newFields) {
|
||||
newFields.put("deletedSource", finalDeletedSource);
|
||||
|
@ -451,15 +430,15 @@ public class MediaStoreImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
class MediaStoreMoveDestination {
|
||||
final String volumeName;
|
||||
final String volumeNameForMediaStore;
|
||||
final String volumePath;
|
||||
final String relativePath;
|
||||
final String fullPath;
|
||||
|
||||
MediaStoreMoveDestination(Activity activity, String destinationDir) {
|
||||
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
|
||||
fullPath = destinationDir;
|
||||
volumeName = getVolumeName(activity, destinationDir);
|
||||
volumePath = StorageUtils.getVolumePath(activity, destinationDir).orElse(null);
|
||||
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
|
||||
volumePath = StorageUtils.getVolumePath(context, destinationDir).orElse(null);
|
||||
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.UriPermission;
|
||||
import android.net.Uri;
|
||||
|
@ -11,12 +12,19 @@ import android.util.Log;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
|
||||
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.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class PermissionManager {
|
||||
|
@ -27,40 +35,15 @@ public class PermissionManager {
|
|||
// permission request code to pending runnable
|
||||
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
|
||||
|
||||
public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) {
|
||||
return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null;
|
||||
}
|
||||
|
||||
// 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));
|
||||
public static void requestVolumeAccess(@NonNull Activity activity, @NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) {
|
||||
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));
|
||||
|
||||
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);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
StorageVolume volume = sm.getStorageVolume(new File(path));
|
||||
if (volume != null) {
|
||||
intent = volume.createOpenDocumentTreeIntent();
|
||||
}
|
||||
|
@ -75,25 +58,109 @@ public class PermissionManager {
|
|||
ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null);
|
||||
}
|
||||
|
||||
public static void onPermissionResult(Activity activity, int requestCode, boolean granted, Uri treeUri) {
|
||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", granted=" + granted);
|
||||
public static void onPermissionResult(int requestCode, @Nullable Uri treeUri) {
|
||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=" + requestCode + ", treeUri=" + treeUri);
|
||||
boolean granted = treeUri != null;
|
||||
|
||||
PendingPermissionHandler handler = pendingPermissionMap.remove(requestCode);
|
||||
if (handler == null) return;
|
||||
StorageUtils.setVolumeTreeUri(activity, handler.volumePath, treeUri.toString());
|
||||
|
||||
Runnable runnable = granted ? handler.onGranted : handler.onDenied;
|
||||
if (runnable == null) return;
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
static class PendingPermissionHandler {
|
||||
String volumePath;
|
||||
Runnable onGranted;
|
||||
Runnable onDenied;
|
||||
public static Optional<String> getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) {
|
||||
return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst();
|
||||
}
|
||||
|
||||
PendingPermissionHandler(String volumePath, Runnable onGranted, Runnable onDenied) {
|
||||
this.volumePath = volumePath;
|
||||
public static List<Map<String, String>> getInaccessibleDirectories(@NonNull Context context, @NonNull List<String> dirPaths) {
|
||||
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.onDenied = onDenied;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.provider.MediaStore;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
@ -21,9 +22,6 @@ import com.commonsware.cwac.document.DocumentFileCompat;
|
|||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
|
@ -31,12 +29,13 @@ import java.io.InputStream;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class StorageUtils {
|
||||
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||
|
@ -51,35 +50,37 @@ public class StorageUtils {
|
|||
// primary volume path, with trailing "/"
|
||||
private static String mPrimaryVolumePath;
|
||||
|
||||
private static String getPrimaryVolumePath() {
|
||||
public static String getPrimaryVolumePath() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath();
|
||||
}
|
||||
return mPrimaryVolumePath;
|
||||
}
|
||||
|
||||
public static String[] getVolumePaths(Context context) {
|
||||
public static String[] getVolumePaths(@NonNull Context context) {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Iterator<String> getPathStepIterator(Context context, @NonNull String anyPath) {
|
||||
Optional<String> volumePathOpt = getVolumePath(context, anyPath);
|
||||
if (!volumePathOpt.isPresent()) return null;
|
||||
private static Iterator<String> getPathStepIterator(@NonNull Context context, @NonNull String anyPath, @Nullable String root) {
|
||||
if (root == null) {
|
||||
root = getVolumePath(context, anyPath).orElse(null);
|
||||
if (root == null) return null;
|
||||
}
|
||||
|
||||
String relativePath = null, filename = null;
|
||||
int lastSeparatorIndex = anyPath.lastIndexOf(File.separator) + 1;
|
||||
int volumePathLength = volumePathOpt.get().length();
|
||||
if (lastSeparatorIndex > volumePathLength) {
|
||||
int rootLength = root.length();
|
||||
if (lastSeparatorIndex > rootLength) {
|
||||
filename = anyPath.substring(lastSeparatorIndex);
|
||||
relativePath = anyPath.substring(volumePathLength, lastSeparatorIndex);
|
||||
relativePath = anyPath.substring(rootLength, lastSeparatorIndex);
|
||||
}
|
||||
if (relativePath == null) return null;
|
||||
|
||||
|
@ -119,7 +120,21 @@ public class StorageUtils {
|
|||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
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) {
|
||||
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||
|
@ -210,43 +225,86 @@ public class StorageUtils {
|
|||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
// serialized map from storage volume paths to their document tree URIs, from the Documents Provider
|
||||
// e.g. "/storage/12A9-8B42" -> "content://com.android.externalstorage.documents/tree/12A9-8B42%3A"
|
||||
private static final String PREF_VOLUME_TREE_URIS = "volume_tree_uris";
|
||||
|
||||
public static void setVolumeTreeUri(Activity activity, String volumePath, String treeUri) {
|
||||
Map<String, String> map = getVolumeTreeUris(activity);
|
||||
map.put(volumePath, treeUri);
|
||||
|
||||
SharedPreferences.Editor editor = activity.getPreferences(Context.MODE_PRIVATE).edit();
|
||||
String json = new JSONObject(map).toString();
|
||||
editor.putString(PREF_VOLUME_TREE_URIS, json);
|
||||
editor.apply();
|
||||
private static Optional<String> getVolumeUuidForTreeUri(@NonNull Context context, @NonNull String anyPath) {
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary()) {
|
||||
return Optional.of("primary");
|
||||
}
|
||||
String uuid = volume.getUuid();
|
||||
if (uuid != null) {
|
||||
return Optional.of(uuid.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=" + anyPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
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) {
|
||||
private static Optional<String> getVolumePathFromTreeUriUuid(@NonNull Context context, @NonNull String uuid) {
|
||||
if (uuid.equals("primary")) {
|
||||
return Optional.of(getPrimaryVolumePath());
|
||||
}
|
||||
StorageManager sm = context.getSystemService(StorageManager.class);
|
||||
if (sm != null) {
|
||||
for (String volumePath : StorageUtils.getVolumePaths(context)) {
|
||||
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);
|
||||
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
|
||||
if (volume != null && uuid.equalsIgnoreCase(volume.getUuid())) {
|
||||
return Optional.of(volumePath);
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.w(LOG_TAG, "failed to read volume tree URIs from preferences", e);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=" + uuid);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<String> getVolumeTreeUriForPath(Activity activity, String anyPath) {
|
||||
return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath));
|
||||
// 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
|
||||
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)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
Uri docUri = MediaStore.getDocumentUri(activity, mediaUri);
|
||||
Uri docUri = MediaStore.getDocumentUri(context, mediaUri);
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(activity, docUri);
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri);
|
||||
}
|
||||
}
|
||||
// fallback for older APIs
|
||||
Uri volumeTreeUri = PermissionManager.getVolumeTreeUri(activity, anyPath);
|
||||
Optional<DocumentFileCompat> docFile = getDocumentFileFromVolumeTree(activity, volumeTreeUri, anyPath);
|
||||
return docFile.orElse(null);
|
||||
return getVolumePath(context, anyPath)
|
||||
.flatMap(volumePath -> convertDirPathToTreeUri(context, volumePath)
|
||||
.flatMap(treeUri -> getDocumentFileFromVolumeTree(context, treeUri, anyPath)))
|
||||
.orElse(null);
|
||||
|
||||
}
|
||||
// good old `File`
|
||||
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 null if directory does not exist and could not be created
|
||||
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Activity activity, @NonNull String directoryPath) {
|
||||
if (requireAccessPermission(directoryPath)) {
|
||||
Uri rootTreeUri = PermissionManager.getVolumeTreeUri(activity, directoryPath);
|
||||
DocumentFileCompat parentFile = DocumentFileCompat.fromTreeUri(activity, rootTreeUri);
|
||||
public static DocumentFileCompat createDirectoryIfAbsent(@NonNull Context context, @NonNull String dirPath) {
|
||||
if (!dirPath.endsWith(File.separator)) {
|
||||
dirPath += File.separator;
|
||||
}
|
||||
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 (!directoryPath.endsWith(File.separator)) {
|
||||
directoryPath += File.separator;
|
||||
}
|
||||
Iterator<String> pathIterator = getPathStepIterator(activity, directoryPath);
|
||||
Iterator<String> pathIterator = getPathStepIterator(context, dirPath, grantedDir);
|
||||
while (pathIterator != null && pathIterator.hasNext()) {
|
||||
String dirName = pathIterator.next();
|
||||
DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName);
|
||||
|
@ -304,10 +369,10 @@ public class StorageUtils {
|
|||
}
|
||||
return parentFile;
|
||||
} else {
|
||||
File directory = new File(directoryPath);
|
||||
File directory = new File(dirPath);
|
||||
if (!directory.exists()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -323,23 +388,19 @@ public class StorageUtils {
|
|||
temp.deleteOnExit();
|
||||
return temp.getPath();
|
||||
} 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;
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) {
|
||||
if (rootTreeUri == null || path == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<DocumentFileCompat> getDocumentFileFromVolumeTree(Context context, @NonNull Uri rootTreeUri, @NonNull String anyPath) {
|
||||
DocumentFileCompat documentFile = DocumentFileCompat.fromTreeUri(context, rootTreeUri);
|
||||
if (documentFile == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next());
|
||||
if (documentFile == null) {
|
||||
|
@ -378,7 +439,7 @@ public class StorageUtils {
|
|||
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) {
|
||||
// we get a permission denial if we require original from a provider other than the media store
|
||||
if (isMediaStoreContentUri(uri)) {
|
||||
|
@ -388,7 +449,7 @@ public class StorageUtils {
|
|||
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();
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
|
@ -399,8 +460,28 @@ public class StorageUtils {
|
|||
}
|
||||
retriever.setDataSource(context, uri);
|
||||
} 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,10 +45,10 @@ class _AvesAppState extends State<AvesApp> {
|
|||
scaffoldBackgroundColor: Colors.grey[900],
|
||||
buttonColor: accentColor,
|
||||
toggleableActiveColor: accentColor,
|
||||
tooltipTheme: const TooltipThemeData(
|
||||
tooltipTheme: TooltipThemeData(
|
||||
verticalOffset: 32,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
appBarTheme: AppBarTheme(
|
||||
textTheme: TextTheme(
|
||||
headline6: TextStyle(
|
||||
fontSize: 20,
|
||||
|
@ -58,12 +58,12 @@ class _AvesAppState extends State<AvesApp> {
|
|||
),
|
||||
),
|
||||
),
|
||||
home: FutureBuilder(
|
||||
home: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, AsyncSnapshot<void> snapshot) {
|
||||
if (snapshot.hasError) return const Icon(AIcons.error);
|
||||
if (snapshot.connectionState != ConnectionState.done) return const Scaffold();
|
||||
return settings.hasAcceptedTerms ? const HomePage() : const WelcomePage();
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Icon(AIcons.error);
|
||||
if (snapshot.connectionState != ConnectionState.done) return Scaffold();
|
||||
return settings.hasAcceptedTerms ? HomePage() : WelcomePage();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
@ -34,7 +34,7 @@ class MimeFilter extends CollectionFilter {
|
|||
_label ??= lowMime.split('/')[0].toUpperCase();
|
||||
} else {
|
||||
_filter = (entry) => entry.mimeType == lowMime;
|
||||
if (lowMime == MimeTypes.SVG) {
|
||||
if (lowMime == MimeTypes.svg) {
|
||||
_label = 'SVG';
|
||||
}
|
||||
_label ??= lowMime.split('/')[1].toUpperCase();
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart';
|
|||
class QueryFilter extends CollectionFilter {
|
||||
static const type = 'query';
|
||||
|
||||
static final exactRegex = RegExp('^"(.*)"\$');
|
||||
static final RegExp exactRegex = RegExp('^"(.*)"\$');
|
||||
|
||||
final String query;
|
||||
final bool colorful;
|
||||
|
@ -39,7 +39,7 @@ class QueryFilter extends CollectionFilter {
|
|||
bool get isUnique => false;
|
||||
|
||||
@override
|
||||
String get label => '${query}';
|
||||
String get label => '$query';
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size);
|
||||
|
|
|
@ -152,10 +152,10 @@ class ImageEntry {
|
|||
|
||||
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)
|
||||
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');
|
||||
|
||||
|
@ -167,7 +167,7 @@ class ImageEntry {
|
|||
|
||||
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;
|
||||
|
||||
|
@ -270,7 +270,7 @@ class ImageEntry {
|
|||
|
||||
final coordinates = Coordinates(latitude, longitude);
|
||||
try {
|
||||
final call = () => Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||||
Future<List<Address>> call() => Geocoder.local.findAddressesFromCoordinates(coordinates);
|
||||
final addresses = await (background
|
||||
? servicePolicy.call(
|
||||
call,
|
||||
|
|
|
@ -179,11 +179,12 @@ class AddressDetails {
|
|||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FavouriteRow {
|
||||
final int contentId;
|
||||
final String path;
|
||||
|
||||
FavouriteRow({
|
||||
const FavouriteRow({
|
||||
this.contentId,
|
||||
this.path,
|
||||
});
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
class MimeTypes {
|
||||
static const String ANY_IMAGE = 'image/*';
|
||||
static const String GIF = 'image/gif';
|
||||
static const String HEIC = 'image/heic';
|
||||
static const String HEIF = 'image/heif';
|
||||
static const String JPEG = 'image/jpeg';
|
||||
static const String PNG = 'image/png';
|
||||
static const String SVG = 'image/svg+xml';
|
||||
static const String WEBP = 'image/webp';
|
||||
static const String anyImage = 'image/*';
|
||||
static const String gif = 'image/gif';
|
||||
static const String heic = 'image/heic';
|
||||
static const String heif = 'image/heif';
|
||||
static const String jpeg = 'image/jpeg';
|
||||
static const String png = 'image/png';
|
||||
static const String svg = 'image/svg+xml';
|
||||
static const String webp = 'image/webp';
|
||||
|
||||
static const String ANY_VIDEO = 'video/*';
|
||||
static const String AVI = 'video/avi';
|
||||
static const String MP2T = 'video/mp2t'; // .m2ts
|
||||
static const String MP4 = 'video/mp4';
|
||||
static const String anyVideo = 'video/*';
|
||||
static const String avi = 'video/avi';
|
||||
static const String mp2t = 'video/mp2t'; // .m2ts
|
||||
static const String mp4 = 'video/mp4';
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ class Settings {
|
|||
|
||||
// convenience methods
|
||||
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
bool getBoolOrDefault(String key, bool defaultValue) => _prefs.getKeys().contains(key) ? _prefs.getBool(key) : defaultValue;
|
||||
|
||||
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
|
||||
|
|
|
@ -168,8 +168,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
]);
|
||||
break;
|
||||
case SortFactor.name:
|
||||
final byAlbum = groupBy(_filteredEntries, (ImageEntry entry) => entry.directory);
|
||||
final compare = (a, b) {
|
||||
final byAlbum = groupBy<ImageEntry, String>(_filteredEntries, (entry) => entry.directory);
|
||||
int compare(a, b) {
|
||||
final ua = source.getUniqueAlbumName(a);
|
||||
final ub = source.getUniqueAlbumName(b);
|
||||
final c = compareAsciiUpperCase(ua, ub);
|
||||
|
@ -177,7 +177,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
|||
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
||||
final vb = androidFileUtils.getStorageVolume(b)?.path ?? '';
|
||||
return compareAsciiUpperCase(va, vb);
|
||||
};
|
||||
}
|
||||
sections = SplayTreeMap.of(byAlbum, compare);
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
|||
_rawEntries.addAll(entries);
|
||||
addFolderPath(_rawEntries.map((entry) => entry.directory));
|
||||
invalidateFilterEntryCounts();
|
||||
eventBus.fire(const EntryAddedEvent());
|
||||
eventBus.fire(EntryAddedEvent());
|
||||
}
|
||||
|
||||
void removeEntries(Iterable<ImageEntry> entries) {
|
||||
|
|
|
@ -57,6 +57,7 @@ mixin LocationMixin on SourceBase {
|
|||
newAddresses.add(entry.addressDetails);
|
||||
if (newAddresses.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
newAddresses.clear();
|
||||
}
|
||||
}
|
||||
|
@ -74,7 +75,7 @@ mixin LocationMixin on SourceBase {
|
|||
|
||||
void updateLocations() {
|
||||
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}');
|
||||
sortedPlaces = lister((address) => address.place);
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ mixin TagMixin on SourceBase {
|
|||
|
||||
Future<void> catalogEntries() async {
|
||||
// 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;
|
||||
|
||||
var progressDone = 0;
|
||||
|
@ -37,6 +37,7 @@ mixin TagMixin on SourceBase {
|
|||
newMetadata.add(entry.catalogMetadata);
|
||||
if (newMetadata.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
newMetadata.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
// 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()));
|
||||
|
|
|
@ -18,16 +18,18 @@ class AndroidFileService {
|
|||
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 {
|
||||
final result = await platform.invokeMethod('requireVolumeAccessDialog', <String, dynamic>{
|
||||
'path': path,
|
||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
return result as bool;
|
||||
return (result as List).cast<Map>();
|
||||
} 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`
|
||||
|
|
|
@ -140,7 +140,7 @@ class ImageFileService {
|
|||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'delete',
|
||||
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
}).map((event) => ImageOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
@ -153,7 +153,7 @@ class ImageFileService {
|
|||
try {
|
||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'move',
|
||||
'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(),
|
||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
'copy': copy,
|
||||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
|
@ -192,11 +192,12 @@ class ImageFileService {
|
|||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ImageOpEvent {
|
||||
final bool success;
|
||||
final String uri;
|
||||
|
||||
ImageOpEvent({
|
||||
const ImageOpEvent({
|
||||
this.success,
|
||||
this.uri,
|
||||
});
|
||||
|
@ -226,7 +227,7 @@ class ImageOpEvent {
|
|||
class MoveOpEvent extends ImageOpEvent {
|
||||
final Map newFields;
|
||||
|
||||
MoveOpEvent({bool success, String uri, this.newFields})
|
||||
const MoveOpEvent({bool success, String uri, this.newFields})
|
||||
: super(
|
||||
success: success,
|
||||
uri: uri,
|
||||
|
|
|
@ -26,7 +26,7 @@ class MetadataService {
|
|||
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry, {bool background = false}) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
final call = () async {
|
||||
Future<CatalogMetadata> call() async {
|
||||
try {
|
||||
// return map with:
|
||||
// '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}');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
return background
|
||||
? servicePolicy.call(
|
||||
call,
|
||||
|
|
|
@ -5,7 +5,7 @@ final Map<String, Color> _stringColors = {};
|
|||
Color stringToColor(String string, {double saturation = .8, double lightness = .6}) {
|
||||
var color = _stringColors[string];
|
||||
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();
|
||||
color = HSLColor.fromAHSL(1.0, hue, saturation, lightness).toColor();
|
||||
_stringColors[string] = color;
|
||||
|
|
|
@ -30,5 +30,5 @@ class Durations {
|
|||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
|
||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||
static var staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ extension ExtraDateTime on DateTime {
|
|||
|
||||
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());
|
||||
|
||||
|
|
|
@ -11,20 +11,20 @@ class AboutPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('About'),
|
||||
title: Text('About'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: AnimationLimiter(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
[
|
||||
AppReference(),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
SizedBox(height: 16),
|
||||
Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -92,7 +92,7 @@ class _AppReferenceState extends State<AppReference> {
|
|||
children: [
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||
padding: EdgeInsetsDirectional.only(end: 4),
|
||||
child: FlutterLogo(
|
||||
size: style.fontSize * 1.25,
|
||||
),
|
||||
|
|
|
@ -39,7 +39,7 @@ class _LicensesState extends State<Licenses> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
|
@ -69,7 +69,7 @@ class _LicensesState extends State<Licenses> {
|
|||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
padding: EdgeInsetsDirectional.only(start: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
@ -95,13 +95,13 @@ class _LicensesState extends State<Licenses> {
|
|||
setState(() {});
|
||||
},
|
||||
tooltip: 'Sort',
|
||||
icon: const Icon(AIcons.sort),
|
||||
icon: Icon(AIcons.sort),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Padding(
|
||||
SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
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);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LinkChip(
|
||||
text: package.name,
|
||||
url: package.sourceUrl,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16),
|
||||
padding: EdgeInsetsDirectional.only(start: 16),
|
||||
child: LinkChip(
|
||||
text: package.license,
|
||||
url: package.licenseUrl,
|
||||
|
|
|
@ -144,7 +144,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
// so that we can also detect taps around the title `Text`
|
||||
child: Container(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
padding: const EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing),
|
||||
padding: EdgeInsets.symmetric(horizontal: NavigationToolbar.kMiddleSpacing),
|
||||
color: Colors.transparent,
|
||||
height: kToolbarHeight,
|
||||
child: title,
|
||||
|
@ -155,7 +155,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
animation: collection.selectionChangeNotifier,
|
||||
builder: (context, child) {
|
||||
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 [
|
||||
if (collection.isBrowsing)
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.search),
|
||||
icon: Icon(AIcons.search),
|
||||
onPressed: _goToSearch,
|
||||
),
|
||||
if (collection.isSelecting)
|
||||
|
@ -190,15 +190,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
if (collection.isBrowsing) ...[
|
||||
if (AvesApp.mode == AppMode.main)
|
||||
if (kDebugMode)
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.refresh,
|
||||
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.select,
|
||||
child: MenuRow(text: 'Select', icon: AIcons.select),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.stats,
|
||||
child: MenuRow(text: 'Stats', icon: AIcons.stats),
|
||||
),
|
||||
|
@ -207,27 +207,27 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
PopupMenuItem(
|
||||
value: CollectionAction.copy,
|
||||
enabled: hasSelection,
|
||||
child: const MenuRow(text: 'Copy to album'),
|
||||
child: MenuRow(text: 'Copy to album'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.move,
|
||||
enabled: hasSelection,
|
||||
child: const MenuRow(text: 'Move to album'),
|
||||
child: MenuRow(text: 'Move to album'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.refreshMetadata,
|
||||
enabled: hasSelection,
|
||||
child: const MenuRow(text: 'Refresh metadata'),
|
||||
child: MenuRow(text: 'Refresh metadata'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectAll,
|
||||
child: MenuRow(text: 'Select all'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectNone,
|
||||
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,
|
||||
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,
|
||||
child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuDivider(),
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
|
|
@ -24,10 +24,10 @@ class EmptyContent extends StatelessWidget {
|
|||
size: 64,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontFamily: 'Concourse',
|
||||
|
|
|
@ -18,7 +18,7 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
super(key: key);
|
||||
|
||||
@override
|
||||
final Size preferredSize = const Size.fromHeight(preferredHeight);
|
||||
final Size preferredSize = Size.fromHeight(preferredHeight);
|
||||
|
||||
@override
|
||||
_FilterBarState createState() => _FilterBarState();
|
||||
|
@ -85,8 +85,8 @@ class _FilterBarState extends State<FilterBar> {
|
|||
key: _animatedListKey,
|
||||
initialItemCount: widget.filters.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
physics: BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
itemBuilder: (context, index, animation) {
|
||||
if (index >= widget.filters.length) return null;
|
||||
return _buildChip(widget.filters.toList()[index]);
|
||||
|
@ -98,7 +98,7 @@ class _FilterBarState extends State<FilterBar> {
|
|||
|
||||
Padding _buildChip(CollectionFilter filter) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Center(
|
||||
child: AvesFilterChip(
|
||||
key: ValueKey(filter),
|
||||
|
|
|
@ -29,7 +29,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
|||
leading: albumIcon,
|
||||
title: albumName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(folderPath)
|
||||
? const Icon(
|
||||
? Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: Color(0xFF757575),
|
||||
|
|
|
@ -54,7 +54,7 @@ class SectionHeader extends StatelessWidget {
|
|||
height: height,
|
||||
child: header,
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildAlbumSectionHeader() {
|
||||
|
@ -128,7 +128,9 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
return Container(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
padding: padding,
|
||||
constraints: const BoxConstraints(minHeight: leadingDimension),
|
||||
constraints: BoxConstraints(minHeight: leadingDimension),
|
||||
child: GestureDetector(
|
||||
onTap: () => _toggleSectionSelection(context),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
|
@ -144,6 +146,7 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
child: leading,
|
||||
)
|
||||
: null,
|
||||
onPressed: () => _toggleSectionSelection(context),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
|
@ -161,18 +164,32 @@ class TitleSectionHeader extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
final dynamic sectionKey;
|
||||
final WidgetBuilder browsingBuilder;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const SectionSelectableLeading({
|
||||
Key key,
|
||||
@required this.sectionKey,
|
||||
@required this.browsingBuilder,
|
||||
@required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
static const leadingDimension = TitleSectionHeader.leadingDimension;
|
||||
|
@ -196,18 +213,12 @@ class SectionSelectableLeading extends StatelessWidget {
|
|||
),
|
||||
child: IconButton(
|
||||
iconSize: 26,
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
alignment: Alignment.topLeft,
|
||||
icon: Icon(selected ? AIcons.selected : AIcons.unselected),
|
||||
onPressed: () {
|
||||
if (selected) {
|
||||
collection.removeFromSelection(sectionEntries);
|
||||
} else {
|
||||
collection.addToSelection(sectionEntries);
|
||||
}
|
||||
},
|
||||
onPressed: onPressed,
|
||||
tooltip: selected ? 'Deselect section' : 'Select section',
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 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(
|
||||
duration: Durations.sectionHeaderAnimation,
|
||||
switchInCurve: Curves.easeInOut,
|
||||
|
|
|
@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/album/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/album/grid/tile_extent_manager.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -110,7 +109,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
sectionKey: sectionKey,
|
||||
height: headerExtent,
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,25 +144,6 @@ class SectionedListLayout {
|
|||
final top = sectionLayout.indexToLayoutOffset(listIndex);
|
||||
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 {
|
||||
|
|
|
@ -26,7 +26,7 @@ class CollectionListSliver extends StatelessWidget {
|
|||
(context, index) {
|
||||
if (index >= childCount) return 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,
|
||||
addAutomaticKeepAlives: false,
|
||||
|
|
|
@ -62,7 +62,7 @@ class _GridScaleGestureDetectorState extends State<GridScaleGestureDetector> {
|
|||
scrollableBox.hitTest(result, position: details.localFocalPoint);
|
||||
|
||||
// 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);
|
||||
// abort if we cannot find an image to show on overlay
|
||||
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
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
|
|
|
@ -23,7 +23,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (filters.isEmpty) return const SizedBox.shrink();
|
||||
if (filters.isEmpty) return SizedBox.shrink();
|
||||
|
||||
final hasTitle = title != null && title.isNotEmpty;
|
||||
|
||||
|
@ -32,7 +32,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
Widget titleRow;
|
||||
if (hasTitle) {
|
||||
titleRow = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
|
@ -52,7 +52,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
final filtersList = filters.toList();
|
||||
final wrap = Container(
|
||||
key: ValueKey('wrap$title'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
// specify transparent as a workaround to prevent
|
||||
// chip border clipping when the floating app bar is fading
|
||||
color: Colors.transparent,
|
||||
|
@ -75,8 +75,8 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
height: AvesFilterChip.minChipHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
physics: BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= filtersList.length) return null;
|
||||
final filter = filtersList[index];
|
||||
|
@ -85,7 +85,7 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
||||
separatorBuilder: (context, index) => SizedBox(width: 8),
|
||||
itemCount: filtersList.length,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -44,7 +44,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
|
|||
return [
|
||||
if (query.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.clear),
|
||||
icon: Icon(AIcons.clear),
|
||||
onPressed: () {
|
||||
query = '';
|
||||
showSuggestions(context);
|
||||
|
@ -57,23 +57,23 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
|
|||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
final upQuery = query.trim().toUpperCase();
|
||||
final containQuery = (String s) => s.toUpperCase().contains(upQuery);
|
||||
bool containQuery(String s) => s.toUpperCase().contains(upQuery);
|
||||
return SafeArea(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: expandedSectionNotifier,
|
||||
builder: (context, expandedSection, child) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
_buildFilterRow(
|
||||
context: context,
|
||||
filters: [
|
||||
_buildQueryFilter(false),
|
||||
FavouriteFilter(),
|
||||
MimeFilter(MimeTypes.ANY_IMAGE),
|
||||
MimeFilter(MimeTypes.ANY_VIDEO),
|
||||
MimeFilter(MimeTypes.anyImage),
|
||||
MimeFilter(MimeTypes.anyVideo),
|
||||
MimeFilter(MimeFilter.animated),
|
||||
MimeFilter(MimeTypes.SVG),
|
||||
MimeFilter(MimeTypes.svg),
|
||||
].where((f) => f != null && containQuery(f.label)),
|
||||
),
|
||||
StreamBuilder(
|
||||
|
@ -135,7 +135,7 @@ class ImageSearchDelegate extends SearchDelegate<CollectionFilter> {
|
|||
// and possibly trigger a rebuild here
|
||||
_select(context, _buildQueryFilter(true));
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
QueryFilter _buildQueryFilter(bool colorful) {
|
||||
|
|
|
@ -57,7 +57,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = Durations.thumbnailOverlayAnimation;
|
||||
const duration = Durations.thumbnailOverlayAnimation;
|
||||
final fontSize = min(14.0, (extent / 8)).roundToDouble();
|
||||
final iconSize = fontSize * 2;
|
||||
final collection = Provider.of<CollectionLens>(context);
|
||||
|
@ -75,7 +75,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
icon: selected ? AIcons.selected : AIcons.unselected,
|
||||
size: iconSize,
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
: SizedBox.shrink();
|
||||
child = AnimatedSwitcher(
|
||||
duration: duration,
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
|
@ -95,7 +95,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
|||
return child;
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
: SizedBox.shrink();
|
||||
return AnimatedSwitcher(
|
||||
duration: duration,
|
||||
child: child,
|
||||
|
|
|
@ -81,7 +81,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fastImage = Image(
|
||||
key: const ValueKey('LQ'),
|
||||
key: ValueKey('LQ'),
|
||||
image: _fastThumbnailProvider,
|
||||
width: extent,
|
||||
height: extent,
|
||||
|
@ -90,7 +90,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
final image = _sizedThumbnailProvider == null
|
||||
? fastImage
|
||||
: Image(
|
||||
key: const ValueKey('HQ'),
|
||||
key: ValueKey('HQ'),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded) return child;
|
||||
return AnimatedSwitcher(
|
||||
|
|
|
@ -36,7 +36,7 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
final mqSize = mq.item1;
|
||||
final mqHorizontalPadding = mq.item2;
|
||||
|
||||
if (mqSize.isEmpty) return const SizedBox.shrink();
|
||||
if (mqSize.isEmpty) return SizedBox.shrink();
|
||||
|
||||
TileExtentManager.applyTileExtent(mqSize, mqHorizontalPadding, _tileExtentNotifier);
|
||||
final cacheExtent = TileExtentManager.extentMaxForSize(mqSize) * 2;
|
||||
|
@ -159,7 +159,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
primary: true,
|
||||
// workaround to prevent scrolling the app bar away
|
||||
// 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,
|
||||
slivers: [
|
||||
appBar,
|
||||
|
@ -168,7 +168,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
child: _buildEmptyCollectionPlaceholder(collection),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: const CollectionListSliver(),
|
||||
: CollectionListSliver(),
|
||||
SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.viewInsets.bottom,
|
||||
|
@ -211,22 +211,22 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
|||
valueListenable: collection.source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
if (sourceState == SourceState.loading) {
|
||||
return const SizedBox.shrink();
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
if (collection.filters.any((filter) => filter is FavouriteFilter)) {
|
||||
return const EmptyContent(
|
||||
return EmptyContent(
|
||||
icon: AIcons.favourite,
|
||||
text: 'No favourites',
|
||||
);
|
||||
}
|
||||
debugPrint('collection.filters=${collection.filters}');
|
||||
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO)) {
|
||||
return const EmptyContent(
|
||||
if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) {
|
||||
return EmptyContent(
|
||||
icon: AIcons.video,
|
||||
text: 'No videos',
|
||||
);
|
||||
}
|
||||
return const EmptyContent(
|
||||
return EmptyContent(
|
||||
icon: AIcons.image,
|
||||
text: 'No images',
|
||||
);
|
||||
|
|
|
@ -46,7 +46,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
color: Theme.of(context).accentColor,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
|
@ -58,8 +58,8 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
spacing: 16,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
const AvesLogo(size: 64),
|
||||
const Text(
|
||||
AvesLogo(size: 64),
|
||||
Text(
|
||||
'Aves',
|
||||
style: TextStyle(
|
||||
fontSize: 44,
|
||||
|
@ -77,19 +77,19 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
|
||||
final allMediaEntry = _FilteredCollectionNavTile(
|
||||
source: source,
|
||||
leading: const Icon(AIcons.allMedia),
|
||||
leading: Icon(AIcons.allMedia),
|
||||
title: 'All media',
|
||||
filter: null,
|
||||
);
|
||||
final videoEntry = _FilteredCollectionNavTile(
|
||||
source: source,
|
||||
leading: const Icon(AIcons.video),
|
||||
leading: Icon(AIcons.video),
|
||||
title: 'Videos',
|
||||
filter: MimeFilter(MimeTypes.ANY_VIDEO),
|
||||
filter: MimeFilter(MimeTypes.anyVideo),
|
||||
);
|
||||
final favouriteEntry = _FilteredCollectionNavTile(
|
||||
source: source,
|
||||
leading: const Icon(AIcons.favourite),
|
||||
leading: Icon(AIcons.favourite),
|
||||
title: 'Favourites',
|
||||
filter: FavouriteFilter(),
|
||||
);
|
||||
|
@ -97,8 +97,8 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: const Icon(AIcons.info),
|
||||
title: const Text('About'),
|
||||
leading: Icon(AIcons.info),
|
||||
title: Text('About'),
|
||||
onTap: () => _goToAbout(context),
|
||||
),
|
||||
);
|
||||
|
@ -109,20 +109,20 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
videoEntry,
|
||||
favouriteEntry,
|
||||
_buildSpecialAlbumSection(),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
_buildRegularAlbumSection(),
|
||||
_buildCountrySection(),
|
||||
_buildTagSection(),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
aboutEntry,
|
||||
if (kDebugMode) ...[
|
||||
const Divider(),
|
||||
Divider(),
|
||||
SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: const Icon(AIcons.debug),
|
||||
title: const Text('Debug'),
|
||||
leading: Icon(AIcons.debug),
|
||||
title: Text('Debug'),
|
||||
onTap: () => _goToDebug(context),
|
||||
),
|
||||
),
|
||||
|
@ -157,7 +157,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
leading: IconUtils.getAlbumIcon(context: context, album: album),
|
||||
title: uniqueName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||
? const Icon(
|
||||
? Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
|
@ -177,10 +177,10 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
return type != AlbumType.regular && type != AlbumType.app;
|
||||
});
|
||||
|
||||
if (specialAlbums.isEmpty) return const SizedBox.shrink();
|
||||
if (specialAlbums.isEmpty) return SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(),
|
||||
Divider(),
|
||||
...specialAlbums.map((album) => _buildAlbumEntry(album, dense: false)),
|
||||
],
|
||||
);
|
||||
|
@ -192,8 +192,8 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: const Icon(AIcons.album),
|
||||
title: const Text('Albums'),
|
||||
leading: Icon(AIcons.album),
|
||||
title: Text('Albums'),
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
|
@ -214,8 +214,8 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: const Icon(AIcons.location),
|
||||
title: const Text('Countries'),
|
||||
leading: Icon(AIcons.location),
|
||||
title: Text('Countries'),
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
|
@ -236,8 +236,8 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: const Icon(AIcons.tag),
|
||||
title: const Text('Tags'),
|
||||
leading: Icon(AIcons.tag),
|
||||
title: Text('Tags'),
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
|
@ -263,7 +263,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
title: 'Albums',
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => const EmptyContent(
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
|
@ -282,7 +282,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
title: 'Countries',
|
||||
filterEntries: source.getCountryEntries(),
|
||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
||||
emptyBuilder: () => const EmptyContent(
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: 'No countries',
|
||||
),
|
||||
|
@ -301,7 +301,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
title: 'Tags',
|
||||
filterEntries: source.getTagEntries(),
|
||||
filterBuilder: (s) => TagFilter(s),
|
||||
emptyBuilder: () => const EmptyContent(
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: 'No tags',
|
||||
),
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -10,19 +12,17 @@ class CreateAlbumDialog extends StatefulWidget {
|
|||
|
||||
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
Set<StorageVolume> allVolumes;
|
||||
StorageVolume primaryVolume, selectedVolume;
|
||||
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
|
||||
Set<StorageVolume> _allVolumes;
|
||||
StorageVolume _primaryVolume, _selectedVolume;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TODO TLAD improve new album default name
|
||||
_nameController.text = 'Album 1';
|
||||
|
||||
allVolumes = androidFileUtils.storageVolumes;
|
||||
primaryVolume = allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => allVolumes.first);
|
||||
selectedVolume = primaryVolume;
|
||||
_allVolumes = androidFileUtils.storageVolumes;
|
||||
_primaryVolume = _allVolumes.firstWhere((volume) => volume.isPrimary, orElse: () => _allVolumes.first);
|
||||
_selectedVolume = _primaryVolume;
|
||||
_initAlbumName();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -34,20 +34,20 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New Album'),
|
||||
title: Text('New Album'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (allVolumes.length > 1) ...[
|
||||
if (_allVolumes.length > 1) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Storage:'),
|
||||
const SizedBox(width: 8),
|
||||
Text('Storage:'),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButton<StorageVolume>(
|
||||
isExpanded: true,
|
||||
items: allVolumes
|
||||
items: _allVolumes
|
||||
.map((volume) => DropdownMenuItem(
|
||||
value: volume,
|
||||
child: Text(
|
||||
|
@ -58,37 +58,67 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
),
|
||||
))
|
||||
.toList(),
|
||||
value: selectedVolume,
|
||||
onChanged: (volume) => setState(() => selectedVolume = volume),
|
||||
value: _selectedVolume,
|
||||
onChanged: (volume) {
|
||||
_selectedVolume = volume;
|
||||
_checkAlbumExists();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
TextField(
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _existsNotifier,
|
||||
builder: (context, exists, child) {
|
||||
return TextField(
|
||||
controller: _nameController,
|
||||
// autofocus: true,
|
||||
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: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel'.toUpperCase()),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context, _buildAlbumPath()),
|
||||
onPressed: () => _submit(context),
|
||||
child: Text('Create'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _buildAlbumPath() {
|
||||
final newName = _nameController.text;
|
||||
if (newName == null || newName.isEmpty) return null;
|
||||
return join(selectedVolume == primaryVolume ? androidFileUtils.dcimPath : selectedVolume.path, newName);
|
||||
String _buildAlbumPath(String name) {
|
||||
if (name == null || name.isEmpty) return '';
|
||||
return join(_selectedVolume.path, 'Pictures', name);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -118,9 +118,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
Future<void> _showDeleteDialog(BuildContext context, ImageEntry entry) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
content: const Text('Are you sure?'),
|
||||
content: Text('Are you sure?'),
|
||||
actions: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
|
@ -11,7 +11,7 @@ mixin FeedbackMixin {
|
|||
void showFeedback(BuildContext context, String message) {
|
||||
_flushbar = Flushbar(
|
||||
message: message,
|
||||
margin: const EdgeInsets.all(8),
|
||||
margin: EdgeInsets.all(8),
|
||||
borderRadius: 8,
|
||||
borderColor: Colors.white30,
|
||||
borderWidth: 0.5,
|
||||
|
|
|
@ -1,34 +1,30 @@
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
mixin PermissionAwareMixin {
|
||||
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 {
|
||||
final volumes = paths.map(androidFileUtils.getStorageVolume).toSet();
|
||||
final ungrantedVolumes = (await Future.wait<Tuple2<StorageVolume, bool>>(
|
||||
volumes.map(
|
||||
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||
(granted) => Tuple2(volume, granted),
|
||||
),
|
||||
),
|
||||
))
|
||||
.where((t) => t.item2)
|
||||
.map((t) => t.item1)
|
||||
.toList();
|
||||
while (ungrantedVolumes.isNotEmpty) {
|
||||
final volume = ungrantedVolumes.first;
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
while (true) {
|
||||
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs == null) return false;
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
final dir = dirs.first;
|
||||
final volumePath = dir['volumePath'] as String;
|
||||
final volumeDescription = dir['volumeDescription'] as String;
|
||||
final relativeDir = dir['relativeDir'] as String;
|
||||
final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const 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.'),
|
||||
title: Text('Storage Volume Access'),
|
||||
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: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -45,15 +41,11 @@ mixin PermissionAwareMixin {
|
|||
// abort if the user cancels in Flutter
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
final granted = await AndroidFileService.requestVolumeAccess(volume.path);
|
||||
debugPrint('$runtimeType _checkStoragePermission with volume=${volume.path} got granted=$granted');
|
||||
if (granted) {
|
||||
ungrantedVolumes.remove(volume);
|
||||
} else {
|
||||
final granted = await AndroidFileService.requestVolumeAccess(volumePath);
|
||||
if (!granted) {
|
||||
// abort if the user denies access from the native dialog
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
leading: const BackButton(),
|
||||
leading: BackButton(),
|
||||
title: Text(copy ? 'Copy to Album' : 'Move to Album'),
|
||||
actions: [
|
||||
IconButton(
|
||||
|
@ -90,7 +90,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
),
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => const EmptyContent(
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
|
@ -100,26 +100,26 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
),
|
||||
);
|
||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||
if (!await checkStoragePermissionForPaths(context, [destinationAlbum])) return;
|
||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
||||
|
||||
final selection = collection.selection.toList();
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
||||
_showOpReport(
|
||||
_showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
|
||||
onDone: (Set<MoveOpEvent> processed) async {
|
||||
onDone: (processed) async {
|
||||
debugPrint('$runtimeType _moveSelection onDone');
|
||||
final movedOps = processed.where((e) => e.success);
|
||||
final movedCount = movedOps.length;
|
||||
final selectionCount = selection.length;
|
||||
if (movedCount < selectionCount) {
|
||||
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 {
|
||||
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) {
|
||||
final fromAlbums = <String>{};
|
||||
|
@ -187,9 +187,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
builder: (context) {
|
||||
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: [
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -207,17 +207,17 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
|
||||
_showOpReport(
|
||||
_showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
selection: selection,
|
||||
opStream: ImageFileService.delete(selection),
|
||||
onDone: (Set<ImageOpEvent> processed) {
|
||||
onDone: (processed) {
|
||||
final deletedUris = processed.where((e) => e.success).map((e) => e.uri);
|
||||
final deletedCount = deletedUris.length;
|
||||
final selectionCount = selection.length;
|
||||
if (deletedCount < selectionCount) {
|
||||
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) {
|
||||
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`
|
||||
// as it could be called multiple times
|
||||
final onComplete = () => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
|
||||
opStream.listen(
|
||||
(event) => processed.add(event),
|
||||
processed.add,
|
||||
onError: (error) {
|
||||
debugPrint('_showOpReport error=$error');
|
||||
onComplete();
|
||||
|
@ -258,7 +258,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
child: StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
builder: (context, snapshot) {
|
||||
Widget child = const SizedBox.shrink();
|
||||
Widget child = SizedBox.shrink();
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.active) {
|
||||
final percent = processed.length.toDouble() / selection.length;
|
||||
child = CircularPercentIndicator(
|
||||
|
|
|
@ -32,7 +32,7 @@ class SourceStateAwareAppBarTitle extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
child: sourceState == SourceState.ready
|
||||
? const SizedBox.shrink()
|
||||
? SizedBox.shrink()
|
||||
: SourceStateSubtitle(
|
||||
source: source,
|
||||
),
|
||||
|
@ -68,7 +68,7 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
}
|
||||
final subtitleStyle = Theme.of(context).textTheme.caption;
|
||||
return subtitle == null
|
||||
? const SizedBox.shrink()
|
||||
? SizedBox.shrink()
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
@ -76,10 +76,10 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
StreamBuilder<ProgressEvent>(
|
||||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink();
|
||||
if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink();
|
||||
final progress = snapshot.data;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
padding: EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
|
|
|
@ -66,7 +66,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
Widget build(BuildContext context) {
|
||||
final hasBackground = widget.background != null;
|
||||
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(
|
||||
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
|
||||
|
@ -74,7 +74,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
children: [
|
||||
if (leading != null) ...[
|
||||
leading,
|
||||
const SizedBox(width: AvesFilterChip.padding),
|
||||
SizedBox(width: AvesFilterChip.padding),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
|
@ -85,7 +85,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: AvesFilterChip.padding),
|
||||
SizedBox(width: AvesFilterChip.padding),
|
||||
trailing,
|
||||
],
|
||||
],
|
||||
|
@ -102,7 +102,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
}
|
||||
|
||||
content = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2),
|
||||
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2),
|
||||
child: content,
|
||||
);
|
||||
|
||||
|
@ -112,7 +112,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
color: Colors.black54,
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2.copyWith(
|
||||
shadows: const [
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black87,
|
||||
offset: Offset(0.5, 1.0),
|
||||
|
@ -128,7 +128,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
final borderRadius = AvesFilterChip.borderRadius;
|
||||
|
||||
Widget chip = Container(
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: AvesFilterChip.minChipWidth,
|
||||
maxWidth: AvesFilterChip.maxChipWidth,
|
||||
minHeight: AvesFilterChip.minChipHeight,
|
||||
|
@ -157,9 +157,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
}
|
||||
: null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder(
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
builder: (context, AsyncSnapshot<Color> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
final outlineColor = snapshot.hasData ? snapshot.data : Colors.transparent;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
|
@ -171,7 +171,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
),
|
||||
position: DecorationPosition.foreground,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -51,11 +51,11 @@ class MediaStoreSource extends CollectionSource {
|
|||
var refreshCount = 10;
|
||||
const refreshCountMax = 1000;
|
||||
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[];
|
||||
final addPendingEntries = () {
|
||||
void addPendingEntries() {
|
||||
allNewEntries.addAll(pendingNewEntries);
|
||||
addAll(pendingNewEntries);
|
||||
pendingNewEntries.clear();
|
||||
};
|
||||
}
|
||||
ImageFileService.getImageEntries(knownEntryMap).listen(
|
||||
(entry) {
|
||||
pendingNewEntries.add(entry);
|
||||
|
|
|
@ -131,10 +131,10 @@ class OverlayIcon extends StatelessWidget {
|
|||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(1),
|
||||
margin: EdgeInsets.all(1),
|
||||
padding: text != null ? EdgeInsets.only(right: size / 4) : null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xBB000000),
|
||||
color: Color(0xBB000000),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(size),
|
||||
),
|
||||
|
@ -146,7 +146,7 @@ class OverlayIcon extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
iconChild,
|
||||
const SizedBox(width: 2),
|
||||
SizedBox(width: 2),
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -54,7 +54,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
}
|
||||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, handleError) {
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||
ImageFileService.resumeThumbnail(_cancellationKey);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class LinkChip extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTextStyle.merge(
|
||||
style: (textStyle ?? const TextStyle()).copyWith(color: color),
|
||||
style: (textStyle ?? TextStyle()).copyWith(color: color),
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: () async {
|
||||
|
@ -32,16 +32,16 @@ class LinkChip extends StatelessWidget {
|
|||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading,
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
Text(text),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) => Icon(
|
||||
AIcons.openInNew,
|
||||
|
|
|
@ -20,13 +20,13 @@ class MenuRow extends StatelessWidget {
|
|||
if (checked != null) ...[
|
||||
Opacity(
|
||||
opacity: checked ? 1 : 0,
|
||||
child: const Icon(AIcons.checked),
|
||||
child: Icon(AIcons.checked),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
if (icon != null) ...[
|
||||
Icon(icon),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: Text(text)),
|
||||
],
|
||||
|
|
|
@ -10,21 +10,21 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
@required Color backgroundColor,
|
||||
}) {
|
||||
final scrollThumb = Container(
|
||||
decoration: const BoxDecoration(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
),
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: .5),
|
||||
padding: const EdgeInsets.all(2),
|
||||
margin: EdgeInsets.only(right: .5),
|
||||
padding: EdgeInsets.all(2),
|
||||
child: ClipPath(
|
||||
child: Container(
|
||||
width: 20.0,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
),
|
||||
|
@ -32,13 +32,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
clipper: ArrowClipper(),
|
||||
),
|
||||
);
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Widget labelText,
|
||||
}) {
|
||||
return (backgroundColor, thumbAnimation, labelAnimation, height, {labelText}) {
|
||||
return DraggableScrollbar.buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
|
|
|
@ -12,7 +12,7 @@ class TransitionImage extends StatefulWidget {
|
|||
final ImageProvider image;
|
||||
final double width, height;
|
||||
final ValueListenable<double> animation;
|
||||
final gaplessPlayback = false;
|
||||
final bool gaplessPlayback = false;
|
||||
|
||||
const TransitionImage({
|
||||
@required this.image,
|
||||
|
|
|
@ -7,7 +7,6 @@ import 'package:aves/model/metadata_db.dart';
|
|||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.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/utils/android_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_svg/flutter_svg.dart';
|
||||
import 'package:outline_material_icons/outline_material_icons.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class DebugPage extends StatefulWidget {
|
||||
final CollectionSource source;
|
||||
|
@ -35,7 +33,6 @@ class DebugPageState extends State<DebugPage> {
|
|||
Future<List<CatalogMetadata>> _dbMetadataLoader;
|
||||
Future<List<AddressDetails>> _dbAddressLoader;
|
||||
Future<List<FavouriteRow>> _dbFavouritesLoader;
|
||||
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
|
||||
Future<Map> _envLoader;
|
||||
|
||||
List<ImageEntry> get entries => widget.source.rawEntries;
|
||||
|
@ -44,13 +41,6 @@ class DebugPageState extends State<DebugPage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_startDbReport();
|
||||
_volumePermissionLoader = Future.wait<Tuple2<String, bool>>(
|
||||
androidFileUtils.storageVolumes.map(
|
||||
(volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then(
|
||||
(value) => Tuple2(volume.path, !value),
|
||||
),
|
||||
),
|
||||
);
|
||||
_envLoader = AndroidAppService.getEnv();
|
||||
}
|
||||
|
||||
|
@ -61,8 +51,8 @@ class DebugPageState extends State<DebugPage> {
|
|||
length: 4,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
bottom: const TabBar(
|
||||
title: Text('Debug'),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(OMIcons.whatshot)),
|
||||
Tab(icon: Icon(OMIcons.settings)),
|
||||
|
@ -91,9 +81,9 @@ class DebugPageState extends State<DebugPage> {
|
|||
final withGps = catalogued.where((entry) => entry.hasGps);
|
||||
final located = withGps.where((entry) => entry.isLocated);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('Time dilation'),
|
||||
Text('Time dilation'),
|
||||
Slider(
|
||||
value: timeDilation,
|
||||
onChanged: (v) => setState(() => timeDilation = v),
|
||||
|
@ -102,24 +92,24 @@ class DebugPageState extends State<DebugPage> {
|
|||
divisions: 9,
|
||||
label: '$timeDilation',
|
||||
),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
Text('Entries: ${entries.length}'),
|
||||
Text('Catalogued: ${catalogued.length}'),
|
||||
Text('With GPS: ${withGps.length}'),
|
||||
Text('With address: ${located.length}'),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
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(
|
||||
onPressed: () {
|
||||
imageCache.clear();
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -128,138 +118,138 @@ class DebugPageState extends State<DebugPage> {
|
|||
Expanded(
|
||||
child: Text('SVG cache: ${PictureProvider.cacheCount} items'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () {
|
||||
PictureProvider.clearCache();
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text('Glide disk cache: ?'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => ImageFileService.clearSizedThumbnailDiskCache(),
|
||||
child: const Text('Clear'),
|
||||
onPressed: ImageFileService.clearSizedThumbnailDiskCache,
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
FutureBuilder(
|
||||
Divider(),
|
||||
FutureBuilder<int>(
|
||||
future: _dbFileSizeLoader,
|
||||
builder: (context, AsyncSnapshot<int> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB file size: ${formatFilesize(snapshot.data)}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => metadataDb.reset().then((_) => _startDbReport()),
|
||||
child: const Text('Reset'),
|
||||
child: Text('Reset'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
FutureBuilder<List>(
|
||||
future: _dbEntryLoader,
|
||||
builder: (context, AsyncSnapshot<List> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB entry rows: ${snapshot.data.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => metadataDb.clearEntries().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
FutureBuilder<List>(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, AsyncSnapshot<List> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB date rows: ${snapshot.data.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => metadataDb.clearDates().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
FutureBuilder<List>(
|
||||
future: _dbMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<List> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB metadata rows: ${snapshot.data.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => metadataDb.clearMetadataEntries().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
FutureBuilder<List>(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, AsyncSnapshot<List> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB address rows: ${snapshot.data.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => metadataDb.clearAddresses().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
FutureBuilder<List>(
|
||||
future: _dbFavouritesLoader,
|
||||
builder: (context, AsyncSnapshot<List> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('DB favourite rows: ${snapshot.data.length} (${favourites.count} in memory)'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => favourites.clear().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
child: Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -271,17 +261,17 @@ class DebugPageState extends State<DebugPage> {
|
|||
|
||||
Widget _buildSettingsTabView() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
Expanded(
|
||||
child: Text('Settings'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
RaisedButton(
|
||||
onPressed: () => settings.reset().then((_) => setState(() {})),
|
||||
child: const Text('Reset'),
|
||||
child: Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -297,16 +287,7 @@ class DebugPageState extends State<DebugPage> {
|
|||
|
||||
Widget _buildStorageTabView() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: _volumePermissionLoader,
|
||||
builder: (context, AsyncSnapshot<List<Tuple2<String, bool>>> snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
final permissions = snapshot.data;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
...androidFileUtils.storageVolumes.expand((v) => [
|
||||
Text(v.path),
|
||||
|
@ -316,27 +297,22 @@ class DebugPageState extends State<DebugPage> {
|
|||
'isPrimary': '${v.isPrimary}',
|
||||
'isRemovable': '${v.isRemovable}',
|
||||
'state': '${v.state}',
|
||||
'permission': '${permissions.firstWhere((t) => t.item1 == v.path, orElse: () => null)?.item2 ?? false}',
|
||||
}),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
])
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnvTabView() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
FutureBuilder<Map>(
|
||||
future: _envLoader,
|
||||
builder: (context, AsyncSnapshot<Map> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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')));
|
||||
return InfoRowGroup(data);
|
||||
},
|
||||
|
|
|
@ -106,7 +106,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
hasScrollBody: false,
|
||||
)
|
||||
: SliverPadding(
|
||||
padding: const EdgeInsets.all(AvesFilterChip.outlineWidth),
|
||||
padding: EdgeInsets.all(AvesFilterChip.outlineWidth),
|
||||
sliver: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
|
@ -132,7 +132,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
},
|
||||
childCount: filterKeys.length,
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
|
@ -201,18 +201,18 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
Widget _buildDetails(CollectionFilter filter) {
|
||||
final count = Text(
|
||||
'${source.count(filter)}',
|
||||
style: const TextStyle(color: FilterGridPage.detailColor),
|
||||
style: TextStyle(color: FilterGridPage.detailColor),
|
||||
);
|
||||
return filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: FilterGridPage.detailColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
count,
|
||||
],
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
|
@ -36,8 +37,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Debug'),
|
||||
bottom: const TabBar(
|
||||
title: Text('Debug'),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'DB'),
|
||||
Tab(text: 'Content Resolver'),
|
||||
|
@ -59,13 +60,13 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
Widget _buildDbTabView() {
|
||||
final catalog = widget.entry.catalogMetadata;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
FutureBuilder<DateMetadata>(
|
||||
future: _dbDateLoader,
|
||||
builder: (context, AsyncSnapshot<DateMetadata> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -79,12 +80,12 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<CatalogMetadata>(
|
||||
future: _dbMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<CatalogMetadata> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -105,12 +106,12 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder(
|
||||
SizedBox(height: 16),
|
||||
FutureBuilder<AddressDetails>(
|
||||
future: _dbAddressLoader,
|
||||
builder: (context, AsyncSnapshot<AddressDetails> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -128,7 +129,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
Divider(),
|
||||
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
|
||||
if (catalog != null)
|
||||
InfoRowGroup({
|
||||
|
@ -152,13 +153,13 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
|
||||
Widget _buildContentResolverTabView() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
FutureBuilder(
|
||||
FutureBuilder<Map>(
|
||||
future: _contentResolverMetadataLoader,
|
||||
builder: (context, AsyncSnapshot<Map> snapshot) {
|
||||
builder: (context, snapshot) {
|
||||
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 key = k.toString();
|
||||
var value = v?.toString() ?? 'null';
|
||||
|
@ -168,6 +169,9 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
|
|||
}
|
||||
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
|
||||
}
|
||||
if (key == 'xmp' && v != null && v is Uint8List) {
|
||||
value = String.fromCharCodes(v);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
}));
|
||||
return InfoRowGroup(data);
|
||||
|
|
|
@ -87,7 +87,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
// no bounce at the bottom, to avoid video controller displacement
|
||||
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,
|
||||
curve: Curves.easeOutQuad,
|
||||
));
|
||||
|
@ -178,7 +178,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
final child = ValueListenableBuilder<ImageEntry>(
|
||||
valueListenable: _entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
if (entry == null) return const SizedBox.shrink();
|
||||
if (entry == null) return SizedBox.shrink();
|
||||
return FullscreenTopOverlay(
|
||||
entry: entry,
|
||||
scale: _topOverlayScale,
|
||||
|
@ -459,7 +459,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
Widget build(BuildContext context) {
|
||||
final pages = [
|
||||
// fake page for opacity transition between collection and fullscreen views
|
||||
const SizedBox(),
|
||||
SizedBox(),
|
||||
hasCollection
|
||||
? MultiImagePage(
|
||||
collection: collection,
|
||||
|
@ -494,7 +494,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
|
|||
child: PageView(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: widget.verticalPager,
|
||||
physics: const PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()),
|
||||
onPageChanged: (page) {
|
||||
widget.onVerticalPageChanged(page);
|
||||
_infoPageVisibleNotifier.value = page == pages.length - 1;
|
||||
|
|
|
@ -39,7 +39,7 @@ class MultiImagePageState extends State<MultiImagePage> with AutomaticKeepAliveC
|
|||
child: PageView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: widget.pageController,
|
||||
physics: const PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()),
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
|
|
|
@ -41,7 +41,7 @@ class ImageView extends StatelessWidget {
|
|||
entry: entry,
|
||||
controller: videoController,
|
||||
)
|
||||
: const SizedBox(),
|
||||
: SizedBox(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
scaleStateChangedCallback: onScaleChanged,
|
||||
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
|
||||
// in any case, we should use `Center` + `AspectRatio` + `Fill` so that the transition image
|
||||
// appears as the final image with `PhotoViewComputedScale.contained` for `initialScale`
|
||||
final loadingBuilder = (BuildContext context, ImageProvider imageProvider) {
|
||||
Widget loadingBuilder(BuildContext context, ImageProvider imageProvider) {
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
// enforce original aspect ratio, as some thumbnails aspect ratios slightly differ
|
||||
|
@ -72,7 +72,7 @@ class ImageView extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Widget child;
|
||||
if (entry.isSvg) {
|
||||
|
@ -107,7 +107,7 @@ class ImageView extends StatelessWidget {
|
|||
context,
|
||||
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
|
||||
),
|
||||
loadFailedChild: const EmptyContent(
|
||||
loadFailedChild: EmptyContent(
|
||||
icon: AIcons.error,
|
||||
text: 'Oops!',
|
||||
alignment: Alignment.center,
|
||||
|
|
|
@ -53,7 +53,7 @@ class BasicSection extends StatelessWidget {
|
|||
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
|
||||
final album = entry.directory;
|
||||
final filters = [
|
||||
if (entry.isVideo) MimeFilter(MimeTypes.ANY_VIDEO),
|
||||
if (entry.isVideo) MimeFilter(MimeTypes.anyVideo),
|
||||
if (entry.isAnimated) MimeFilter(MimeFilter.animated),
|
||||
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
|
||||
...tags.map((tag) => TagFilter(tag)),
|
||||
|
@ -65,9 +65,9 @@ class BasicSection extends StatelessWidget {
|
|||
...filters,
|
||||
if (entry.isFavourite) FavouriteFilter(),
|
||||
]..sort();
|
||||
if (effectiveFilters.isEmpty) return const SizedBox.shrink();
|
||||
if (effectiveFilters.isEmpty) return SizedBox.shrink();
|
||||
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(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
|
|
|
@ -40,11 +40,11 @@ class InfoPageState extends State<InfoPage> {
|
|||
|
||||
final appBar = SliverAppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(AIcons.goUp),
|
||||
icon: Icon(AIcons.goUp),
|
||||
onPressed: _goToImage,
|
||||
tooltip: 'Back to image',
|
||||
),
|
||||
title: const Text('Info'),
|
||||
title: Text('Info'),
|
||||
floating: true,
|
||||
);
|
||||
|
||||
|
@ -60,7 +60,7 @@ class InfoPageState extends State<InfoPage> {
|
|||
final mqViewInsetsBottom = mq.item2;
|
||||
final split = mqWidth > 400;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
return ValueListenableBuilder<ImageEntry>(
|
||||
valueListenable: widget.entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
final locationAtTop = split && entry.hasGps;
|
||||
|
@ -77,7 +77,7 @@ class InfoPageState extends State<InfoPage> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
Expanded(child: locationSection),
|
||||
],
|
||||
),
|
||||
|
@ -100,7 +100,7 @@ class InfoPageState extends State<InfoPage> {
|
|||
slivers: [
|
||||
appBar,
|
||||
SliverPadding(
|
||||
padding: horizontalPadding + const EdgeInsets.only(top: 8),
|
||||
padding: horizontalPadding + EdgeInsets.only(top: 8),
|
||||
sliver: basicAndLocationSliver,
|
||||
),
|
||||
SliverPadding(
|
||||
|
@ -165,7 +165,7 @@ class SectionRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const dim = 32.0;
|
||||
final buildDivider = () => const SizedBox(
|
||||
Widget buildDivider() => SizedBox(
|
||||
width: dim,
|
||||
child: Divider(
|
||||
thickness: AvesFilterChip.outlineWidth,
|
||||
|
@ -177,7 +177,7 @@ class SectionRow extends StatelessWidget {
|
|||
children: [
|
||||
buildDivider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: dim,
|
||||
|
@ -196,7 +196,7 @@ class InfoRowGroup extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (keyValues.isEmpty) return const SizedBox.shrink();
|
||||
if (keyValues.isEmpty) return SizedBox.shrink();
|
||||
final lastKey = keyValues.keys.last;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
@ -206,13 +206,13 @@ class InfoRowGroup extends StatelessWidget {
|
|||
children: keyValues.entries
|
||||
.expand(
|
||||
(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'}'),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'Concourse'),
|
||||
style: TextStyle(fontFamily: 'Concourse'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:aves/widgets/common/aves_filter_chip.dart';
|
||||
|
@ -90,7 +90,7 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showTitle)
|
||||
const Padding(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: SectionRow(AIcons.location),
|
||||
),
|
||||
|
@ -105,12 +105,12 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
),
|
||||
if (location.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: InfoRowGroup({'Address': location}),
|
||||
),
|
||||
if (filters.isNotEmpty)
|
||||
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(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
|
@ -126,7 +126,7 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
);
|
||||
} else {
|
||||
_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
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
|
@ -208,24 +208,24 @@ class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Column(children: [
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
icon: Icon(AIcons.zoomIn),
|
||||
onPressed: _controller == null ? null : () => _zoomBy(1),
|
||||
tooltip: 'Zoom in',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
icon: Icon(AIcons.zoomOut),
|
||||
onPressed: _controller == null ? null : () => _zoomBy(-1),
|
||||
tooltip: 'Zoom out',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.openInNew),
|
||||
icon: Icon(AIcons.openInNew),
|
||||
onPressed: () => AndroidAppService.openMap(widget.geoUri),
|
||||
tooltip: 'Show on map...',
|
||||
),
|
||||
|
|
|
@ -65,7 +65,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
Widget build(BuildContext 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 directoriesWithTitle = _metadata.where((dir) => dir.name.isNotEmpty).toList();
|
||||
|
@ -74,7 +74,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0) {
|
||||
return const SectionRow(AIcons.info);
|
||||
return SectionRow(AIcons.info);
|
||||
}
|
||||
if (index < untitledDirectoryCount + 1) {
|
||||
final dir = directoriesWithoutTitle[index - 1];
|
||||
|
@ -91,10 +91,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
|
|||
expandedNotifier: _expandedDirectoryNotifier,
|
||||
title: _DirectoryTitle(dir.name),
|
||||
children: [
|
||||
const Divider(thickness: 1.0, height: 1.0),
|
||||
Divider(thickness: 1.0, height: 1.0),
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: InfoRowGroup(dir.tags),
|
||||
),
|
||||
],
|
||||
|
@ -150,10 +150,10 @@ class _DirectoryTitle extends StatelessWidget {
|
|||
decoration: HighlightDecoration(
|
||||
color: stringToColor(name),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
margin: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
|
|
|
@ -82,15 +82,15 @@ class _FullscreenBottomOverlayState extends State<FullscreenBottomOverlay> {
|
|||
return Container(
|
||||
color: FullscreenOverlay.backgroundColor,
|
||||
padding: viewInsets + viewPadding.copyWith(top: 0),
|
||||
child: FutureBuilder(
|
||||
child: FutureBuilder<OverlayMetadata>(
|
||||
future: _detailLoader,
|
||||
builder: (futureContext, AsyncSnapshot<OverlayMetadata> snapshot) {
|
||||
builder: (futureContext, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) {
|
||||
_lastDetails = snapshot.data;
|
||||
_lastEntry = entry;
|
||||
}
|
||||
return _lastEntry == null
|
||||
? const SizedBox.shrink()
|
||||
? SizedBox.shrink()
|
||||
: Padding(
|
||||
// keep padding inside `FutureBuilder` so that overlay takes no space until data is ready
|
||||
padding: innerPadding,
|
||||
|
@ -134,7 +134,7 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2.copyWith(
|
||||
shadows: const [
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black87,
|
||||
offset: Offset(0.5, 1.0),
|
||||
|
@ -163,12 +163,12 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
|
|||
if (positionTitle.isNotEmpty) Text(positionTitle, strutStyle: Constants.overflowStrutStyle),
|
||||
if (entry.hasGps)
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: _interRowPadding),
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: _LocationRow(entry: entry),
|
||||
),
|
||||
if (twoColumns)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _interRowPadding),
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: subRowWidth, child: _DateRow(entry)),
|
||||
|
@ -178,13 +178,13 @@ class _FullscreenBottomOverlayContent extends AnimatedWidget {
|
|||
)
|
||||
else ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: _interRowPadding),
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _DateRow(entry),
|
||||
),
|
||||
if (hasShootingDetails)
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: _interRowPadding),
|
||||
padding: EdgeInsets.only(top: _interRowPadding),
|
||||
width: subRowWidth,
|
||||
child: _ShootingRow(details),
|
||||
),
|
||||
|
@ -216,8 +216,8 @@ class _LocationRow extends AnimatedWidget {
|
|||
}
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(AIcons.location, size: _iconSize),
|
||||
const SizedBox(width: _iconPadding),
|
||||
Icon(AIcons.location, size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(child: Text(location, strutStyle: Constants.overflowStrutStyle)),
|
||||
],
|
||||
);
|
||||
|
@ -236,8 +236,8 @@ class _DateRow extends StatelessWidget {
|
|||
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(AIcons.date, size: _iconSize),
|
||||
const SizedBox(width: _iconPadding),
|
||||
Icon(AIcons.date, size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(flex: 3, child: Text(dateText, 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) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(AIcons.shooting, size: _iconSize),
|
||||
const SizedBox(width: _iconPadding),
|
||||
Icon(AIcons.shooting, size: _iconSize),
|
||||
SizedBox(width: _iconPadding),
|
||||
Expanded(child: Text(details.aperture, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.exposureTime, strutStyle: Constants.overflowStrutStyle)),
|
||||
Expanded(child: Text(details.focalLength, strutStyle: Constants.overflowStrutStyle)),
|
||||
|
|
|
@ -40,7 +40,7 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
return SafeArea(
|
||||
minimum: (viewInsets ?? EdgeInsets.zero) + (viewPadding ?? EdgeInsets.zero),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(padding),
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: Selector<MediaQueryData, Tuple2<double, Orientation>>(
|
||||
selector: (c, mq) => Tuple2(mq.size.width, mq.orientation),
|
||||
builder: (c, mq, child) {
|
||||
|
@ -126,19 +126,19 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
children: [
|
||||
OverlayButton(
|
||||
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),
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: PopupMenuButton<EntryAction>(
|
||||
itemBuilder: (context) => [
|
||||
...inAppActions.map(_buildPopupMenuItem),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuDivider(),
|
||||
...externalAppActions.map(_buildPopupMenuItem),
|
||||
if (kDebugMode) ...[
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuDivider(),
|
||||
_buildPopupMenuItem(EntryAction.debug),
|
||||
]
|
||||
],
|
||||
|
@ -151,7 +151,7 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
|
||||
Widget _buildOverlayButton(EntryAction action) {
|
||||
Widget child;
|
||||
final onPressed = () => onActionSelected?.call(action);
|
||||
void onPressed() => onActionSelected?.call(action);
|
||||
switch (action) {
|
||||
case EntryAction.toggleFavourite:
|
||||
child = _FavouriteToggler(
|
||||
|
@ -181,13 +181,13 @@ class _TopOverlayRow extends StatelessWidget {
|
|||
}
|
||||
return child != null
|
||||
? Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: padding),
|
||||
padding: EdgeInsetsDirectional.only(end: padding),
|
||||
child: OverlayButton(
|
||||
scale: scale,
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
: SizedBox.shrink();
|
||||
}
|
||||
|
||||
PopupMenuEntry<EntryAction> _buildPopupMenuItem(EntryAction action) {
|
||||
|
@ -269,11 +269,11 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
|
|||
builder: (context, isFavourite, child) {
|
||||
if (widget.isMenuItem) {
|
||||
return isFavourite
|
||||
? const MenuRow(
|
||||
? MenuRow(
|
||||
text: 'Remove from favourites',
|
||||
icon: AIcons.favouriteActive,
|
||||
)
|
||||
: const MenuRow(
|
||||
: MenuRow(
|
||||
text: 'Add to favourites',
|
||||
icon: AIcons.favourite,
|
||||
);
|
||||
|
@ -288,7 +288,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> {
|
|||
),
|
||||
Sweeper(
|
||||
key: ValueKey(widget.entry),
|
||||
builder: (context) => const Icon(AIcons.favourite, color: Colors.redAccent),
|
||||
builder: (context) => Icon(AIcons.favourite, color: Colors.redAccent),
|
||||
toggledNotifier: isFavouriteNotifier,
|
||||
),
|
||||
],
|
||||
|
|
|
@ -98,14 +98,14 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
|
||||
@override
|
||||
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 mqViewInsets = mq.item2;
|
||||
final mqViewPadding = mq.item3;
|
||||
|
||||
final viewInsets = widget.viewInsets ?? mqViewInsets;
|
||||
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(
|
||||
padding: safePadding,
|
||||
|
@ -127,7 +127,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.openInNew),
|
||||
icon: Icon(AIcons.openInNew),
|
||||
onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype),
|
||||
tooltip: 'Open',
|
||||
),
|
||||
|
@ -137,7 +137,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
Expanded(
|
||||
child: _buildProgressBar(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 8),
|
||||
OverlayButton(
|
||||
scale: scale,
|
||||
child: IconButton(
|
||||
|
@ -164,25 +164,25 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
child: BlurredRRect(
|
||||
borderRadius: progressBarBorderRadius,
|
||||
child: GestureDetector(
|
||||
onTapDown: (TapDownDetails details) {
|
||||
onTapDown: (details) {
|
||||
_seekFromTap(details.globalPosition);
|
||||
},
|
||||
onHorizontalDragStart: (DragStartDetails details) {
|
||||
onHorizontalDragStart: (details) {
|
||||
_playingOnDragStart = isPlaying;
|
||||
if (_playingOnDragStart) controller.pause();
|
||||
},
|
||||
onHorizontalDragUpdate: (DragUpdateDetails details) {
|
||||
onHorizontalDragUpdate: (details) {
|
||||
_seekFromTap(details.globalPosition);
|
||||
},
|
||||
onHorizontalDragEnd: (DragEndDetails details) {
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (_playingOnDragStart) controller.play();
|
||||
},
|
||||
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(
|
||||
color: FullscreenOverlay.backgroundColor,
|
||||
border: FullscreenOverlay.buildBorder(context),
|
||||
borderRadius: const BorderRadius.all(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(progressBarBorderRadius),
|
||||
),
|
||||
),
|
||||
|
@ -198,7 +198,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
final position = videoInfo.currentPosition?.floor() ?? 0;
|
||||
return Text(formatDuration(Duration(seconds: position)));
|
||||
}),
|
||||
const Spacer(),
|
||||
Spacer(),
|
||||
Text(entry.durationText),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -60,7 +60,7 @@ class AvesVideoState extends State<AvesVideo> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (controller == null) return const SizedBox();
|
||||
if (controller == null) return SizedBox();
|
||||
return StreamBuilder<IjkStatus>(
|
||||
stream: widget.controller.ijkStatusStream,
|
||||
builder: (context, snapshot) {
|
||||
|
@ -68,8 +68,8 @@ class AvesVideoState extends State<AvesVideo> {
|
|||
return isPlayable(status)
|
||||
? IjkPlayer(
|
||||
mediaController: controller,
|
||||
controllerWidgetBuilder: (controller) => const SizedBox.shrink(),
|
||||
statusWidgetBuilder: (context, controller, status) => const SizedBox.shrink(),
|
||||
controllerWidgetBuilder: (controller) => SizedBox.shrink(),
|
||||
statusWidgetBuilder: (context, controller, status) => SizedBox.shrink(),
|
||||
textureBuilder: (context, controller, info) {
|
||||
var id = controller.textureId;
|
||||
var child = id != null
|
||||
|
|
|
@ -68,7 +68,7 @@ class _HomePageState extends State<HomePage> {
|
|||
// TODO TLAD apply pick mimetype(s)
|
||||
// some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?)
|
||||
String pickMimeTypes = intentData['mimeType'];
|
||||
debugPrint('pick mimeType=' + pickMimeTypes);
|
||||
debugPrint('pick mimeType=$pickMimeTypes');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -92,11 +92,11 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
return FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, AsyncSnapshot<void> snapshot) {
|
||||
if (snapshot.hasError) return const Icon(AIcons.error);
|
||||
if (snapshot.connectionState != ConnectionState.done) return const Scaffold();
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Icon(AIcons.error);
|
||||
if (snapshot.connectionState != ConnectionState.done) return Scaffold();
|
||||
if (AvesApp.mode == AppMode.view) {
|
||||
return SingleFullscreenPage(entry: _viewerEntry);
|
||||
}
|
||||
|
@ -107,7 +107,7 @@ class _HomePageState extends State<HomePage> {
|
|||
sortFactor: settings.collectionSortFactor,
|
||||
));
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
return SizedBox.shrink();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ class FilterTable extends StatelessWidget {
|
|||
final lineHeight = 16 * textScaleFactor;
|
||||
|
||||
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(
|
||||
builder: (context, constraints) {
|
||||
final showPercentIndicator = constraints.maxWidth - (chipWidth + countWidth) > percentIndicatorMinWidth;
|
||||
|
@ -49,7 +49,7 @@ class FilterTable extends StatelessWidget {
|
|||
return TableRow(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AvesFilterChip(
|
||||
filter: filter,
|
||||
|
@ -67,14 +67,14 @@ class FilterTable extends StatelessWidget {
|
|||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
),
|
||||
Text(
|
||||
'${count}',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
'$count',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
columnWidths: const {
|
||||
columnWidths: {
|
||||
0: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(chipWidth)),
|
||||
2: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(countWidth)),
|
||||
},
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/album/empty.dart';
|
||||
|
@ -49,12 +49,12 @@ class StatsPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
Widget child;
|
||||
if (collection.isEmpty) {
|
||||
child = const EmptyContent(
|
||||
child = EmptyContent(
|
||||
icon: AIcons.image,
|
||||
text: 'No images',
|
||||
);
|
||||
} 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 videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/')));
|
||||
final mimeDonuts = Wrap(
|
||||
|
@ -71,7 +71,7 @@ class StatsPage extends StatelessWidget {
|
|||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final lineHeight = 16 * textScaleFactor;
|
||||
final locationIndicator = Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
LinearPercentIndicator(
|
||||
|
@ -80,12 +80,12 @@ class StatsPage extends StatelessWidget {
|
|||
backgroundColor: Colors.white24,
|
||||
progressColor: Theme.of(context).accentColor,
|
||||
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
|
||||
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)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(height: 8),
|
||||
Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'),
|
||||
],
|
||||
),
|
||||
|
@ -103,7 +103,7 @@ class StatsPage extends StatelessWidget {
|
|||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stats'),
|
||||
title: Text('Stats'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: child,
|
||||
|
@ -118,9 +118,9 @@ class StatsPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
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();
|
||||
seriesData.sort((kv1, kv2) {
|
||||
|
@ -158,7 +158,7 @@ class StatsPage extends StatelessWidget {
|
|||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${sum}\n${label(sum)}',
|
||||
'$sum\n${label(sum)}',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
@ -177,12 +177,12 @@ class StatsPage extends StatelessWidget {
|
|||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
padding: EdgeInsetsDirectional.only(end: 8),
|
||||
child: Icon(AIcons.disc, color: stringToColor(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,
|
||||
|
@ -217,7 +217,7 @@ class StatsPage extends StatelessWidget {
|
|||
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
title,
|
||||
style: Constants.titleTextStyle,
|
||||
|
|
|
@ -32,11 +32,11 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
body: SafeArea(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: FutureBuilder(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: FutureBuilder<String>(
|
||||
future: _termsLoader,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
|
||||
final terms = snapshot.data;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
@ -73,21 +73,21 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
return [
|
||||
...(MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? [
|
||||
const AvesLogo(size: 64),
|
||||
const SizedBox(height: 16),
|
||||
AvesLogo(size: 64),
|
||||
SizedBox(height: 16),
|
||||
message,
|
||||
]
|
||||
: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AvesLogo(size: 48),
|
||||
const SizedBox(width: 16),
|
||||
AvesLogo(size: 48),
|
||||
SizedBox(width: 16),
|
||||
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',
|
||||
);
|
||||
final button = RaisedButton(
|
||||
child: const Text('Continue'),
|
||||
child: Text('Continue'),
|
||||
onPressed: _hasAcceptedTerms
|
||||
? () {
|
||||
settings.hasAcceptedTerms = true;
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const HomePage(),
|
||||
builder: (context) => HomePage(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
|
@ -118,11 +118,11 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
button,
|
||||
]
|
||||
: [
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
checkbox,
|
||||
const Spacer(),
|
||||
Spacer(),
|
||||
button,
|
||||
],
|
||||
),
|
||||
|
@ -135,7 +135,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white10,
|
||||
),
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
constraints: BoxConstraints(maxWidth: 460),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Markdown(
|
||||
|
|
66
pubspec.lock
66
pubspec.lock
|
@ -28,7 +28,7 @@ packages:
|
|||
name: barcode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.13.0"
|
||||
version: "1.14.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -103,6 +103,13 @@ packages:
|
|||
url: "git://github.com/deckerst/expansion_tile_card.git"
|
||||
source: git
|
||||
version: "1.0.3"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
firebase_crashlytics:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -295,6 +302,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -346,6 +367,13 @@ packages:
|
|||
url: "git://github.com/deckerst/photo_view.git"
|
||||
source: git
|
||||
version: "0.9.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
platform_detect:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -367,13 +395,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.13"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.1.3"
|
||||
version: "4.3.1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -408,7 +443,14 @@ packages:
|
|||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -497,7 +539,7 @@ packages:
|
|||
name: synchronized
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
version: "2.2.0+2"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -532,7 +574,14 @@ packages:
|
|||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -575,6 +624,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -11,7 +11,7 @@ description: A new Flutter application.
|
|||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.0.10+11
|
||||
version: 1.1.0+12
|
||||
|
||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||
# - does not support content URIs (by default, but trivial by fork)
|
||||
|
|
8
test/widget_test.dart
Normal file
8
test/widget_test.dart
Normal 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());
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue