Merge branch 'develop'

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -152,10 +152,10 @@ class ImageEntry {
bool get isFavourite => favourites.isFavourite(this);
bool get isSvg => mimeType == MimeTypes.SVG;
bool get isSvg => mimeType == MimeTypes.svg;
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.HEIC, MimeTypes.HEIF, MimeTypes.JPEG].contains(mimeType);
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType);
bool get isVideo => mimeType.startsWith('video');
@ -167,7 +167,7 @@ class ImageEntry {
bool get canPrint => !isVideo;
bool get canRotate => canEdit && (mimeType == MimeTypes.JPEG || mimeType == MimeTypes.PNG);
bool get canRotate => canEdit && (mimeType == MimeTypes.jpeg || mimeType == MimeTypes.png);
bool get rotated => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
@ -270,7 +270,7 @@ class ImageEntry {
final coordinates = Coordinates(latitude, longitude);
try {
final call = () => Geocoder.local.findAddressesFromCoordinates(coordinates);
Future<List<Address>> call() => Geocoder.local.findAddressesFromCoordinates(coordinates);
final addresses = await (background
? servicePolicy.call(
call,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -88,7 +88,7 @@ class AndroidAppService {
}
}
static Future<void> share(Set<ImageEntry> entries) async {
static Future<void> share(Iterable<ImageEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps
// 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()));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

8
test/widget_test.dart Normal file
View file

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