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