From dcbd95be01968014ce9ed3813df2218d8389891e Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 19 Jul 2020 17:57:21 +0900 Subject: [PATCH 01/18] improved new album creation dialog --- .../action_delegates/create_album_dialog.dart | 74 +++++++++++++------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/common/action_delegates/create_album_dialog.dart index dae918990..1a8881c86 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/common/action_delegates/create_album_dialog.dart @@ -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 { final TextEditingController _nameController = TextEditingController(); - Set allVolumes; - StorageVolume primaryVolume, selectedVolume; + final ValueNotifier _existsNotifier = ValueNotifier(false); + Set _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 @@ -38,7 +38,7 @@ class _CreateAlbumDialogState extends State { content: Column( mainAxisSize: MainAxisSize.min, children: [ - if (allVolumes.length > 1) ...[ + if (_allVolumes.length > 1) ...[ Row( mainAxisSize: MainAxisSize.min, children: [ @@ -47,7 +47,7 @@ class _CreateAlbumDialogState extends State { Expanded( child: DropdownButton( isExpanded: true, - items: allVolumes + items: _allVolumes .map((volume) => DropdownMenuItem( value: volume, child: Text( @@ -58,18 +58,30 @@ class _CreateAlbumDialogState extends State { ), )) .toList(), - value: selectedVolume, - onChanged: (volume) => setState(() => selectedVolume = volume), + value: _selectedVolume, + onChanged: (volume) { + _selectedVolume = volume; + _checkAlbumExists(); + setState(() {}); + }, ), ), ], ), const SizedBox(height: 16), ], - TextField( - controller: _nameController, -// autofocus: true, - ), + ValueListenableBuilder( + 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), @@ -79,16 +91,34 @@ class _CreateAlbumDialogState extends State { 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 _initAlbumName() async { + var count = 1; + while (true) { + var name = 'Album $count'; + if (!await Directory(_buildAlbumPath(name)).exists()) { + _nameController.text = name; + return; + } + count++; + } + } + + Future _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)); } From 6bd590cde20e97e974dd0e76768766e6f5310f77 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 19 Jul 2020 18:07:23 +0900 Subject: [PATCH 02/18] update tags/locations during cataloguing/locating --- lib/model/source/location.dart | 1 + lib/model/source/tag.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index dfa0d7bea..678351b75 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -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(); } } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 7beb7cf36..48eecace8 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -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(); } } From c86af1945f1d3d4b3ad007c4d161709bb41ffeef Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 19 Jul 2020 19:04:56 +0900 Subject: [PATCH 03/18] API 30: improved media store xmp value in fullscreen debug page --- android/app/build.gradle | 4 ++-- lib/widgets/fullscreen/debug.dart | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index c53db25ad..20c53bb68 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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']] diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 827e0b4a8..091ff4d5a 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -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'; @@ -168,6 +169,9 @@ class _FullscreenDebugPageState extends State { } value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; } + if (key == 'xmp' && v != null && v is Uint8List) { + value = String.fromCharCodes(v); + } return MapEntry(key, value); })); return InfoRowGroup(data); From 6fb73fbf70da26cd741bfe0421023873ae1bcb13 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 19 Jul 2020 19:26:29 +0900 Subject: [PATCH 04/18] API 30: fixed fetching package names/icons --- android/app/src/main/AndroidManifest.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3f54529e3..f245fb437 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,14 @@ --> + + + + + + + + Date: Sun, 19 Jul 2020 21:35:36 +0900 Subject: [PATCH 05/18] API 30: fixed fetching volume paths on first run --- .../thibault/aves/utils/StorageUtils.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 142d5a5cf..378720aac 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -34,6 +34,7 @@ import java.util.Collections; 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; @@ -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 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")); From 51fb36bb7075bdf8c12f11a2f7ff38f4ceb7f5b1 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 19 Jul 2020 22:00:59 +0900 Subject: [PATCH 06/18] API 30: prep to request access by directory, not volume --- .../deckers/thibault/aves/MainActivity.java | 4 +- .../streams/StorageAccessStreamHandler.java | 8 +-- .../aves/model/provider/ImageProvider.java | 15 ----- .../provider/MediaStoreImageProvider.java | 21 ------- .../aves/utils/PermissionManager.java | 56 ++++++++++--------- 5 files changed, 37 insertions(+), 67 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index b34a006fe..e7296457b 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -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(this, 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(this, requestCode, treeUri); } } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java index c1d529dd6..810decc4f 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java @@ -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 argMap = (Map) 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 onGranted = () -> success(!PermissionManager.requireVolumeAccessDialog(activity, path)); Runnable onDenied = () -> success(false); - PermissionManager.requestVolumeAccess(activity, volumePath, onGranted, onDenied); + PermissionManager.requestVolumeAccess(activity, path, onGranted, onDenied); } @Override diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 391d1ca3c..34b912559 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -9,8 +9,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 +30,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; @@ -74,12 +71,6 @@ 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); try { boolean renamed = df != null && df.renameTo(newFilename); @@ -97,12 +88,6 @@ public abstract class ImageProvider { } 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; - } - switch (mimeType) { case MimeTypes.JPEG: rotateJpeg(activity, path, uri, clockwise, callback); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 5a5f0fae8..cc7c15401 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -8,8 +8,6 @@ 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 +33,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; @@ -217,18 +214,6 @@ public class MediaStoreImageProvider extends ImageProvider { SettableFuture 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 { @@ -276,12 +261,6 @@ public class MediaStoreImageProvider extends ImageProvider { @Override public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List 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); if (destinationDirDocFile == null) { callback.onFailure(new Exception("failed to create directory at path=" + destinationDir)); diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 4ab6c92d1..cbbf9beac 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -41,26 +41,15 @@ public class PermissionManager { 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 +64,42 @@ 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(Activity activity, 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()); + + if (granted) { + String requestedPath = handler.path; + if (isTreeUriPath(requestedPath, treeUri)) { + StorageUtils.setVolumeTreeUri(activity, requestedPath, treeUri.toString()); + } else { + granted = false; + } + } Runnable runnable = granted ? handler.onGranted : handler.onDenied; if (runnable == null) return; runnable.run(); } - static class PendingPermissionHandler { - String volumePath; - Runnable onGranted; - Runnable onDenied; + private static boolean isTreeUriPath(String path, Uri treeUri) { + // TODO TLAD check requestedPath match treeUri + // e.g. OK match for path=/storage/emulated/0/, treeUri=content://com.android.externalstorage.documents/tree/primary%3A + // e.g. NO match for path=/storage/10F9-3F13/, treeUri=content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures + Log.d(LOG_TAG, "isTreeUriPath path=" + path + ", treeUri=" + treeUri); + return true; + } - PendingPermissionHandler(String volumePath, Runnable onGranted, Runnable onDenied) { - this.volumePath = volumePath; + static class PendingPermissionHandler { + final String path; + final Runnable onGranted; + final Runnable onDenied; + + PendingPermissionHandler(@NonNull String path, @NonNull Runnable onGranted, @NonNull Runnable onDenied) { + this.path = path; this.onGranted = onGranted; this.onDenied = onDenied; } From 9287979730a3aa1ad10edef28b6770d12e6694ac Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 25 Jul 2020 10:54:58 +0900 Subject: [PATCH 07/18] changed workflow name --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5711c7d39..46752b1c5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Release an APK and an App Bundle on tagging +name: Release on tag on: push: @@ -86,4 +86,4 @@ jobs: packageName: deckers.thibault.aves releaseFile: app-release.aab track: beta - whatsNewDirectory: whatsnew \ No newline at end of file + whatsNewDirectory: whatsnew From e11769ce7767c7261d3949a036b9baf5a491b8d8 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 25 Jul 2020 10:56:14 +0900 Subject: [PATCH 08/18] readme: added badges and screenshots --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb56922e6..a6eb20a03 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ -![Aves logo][] [Get it on Google Play](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] + +
+Aves logo + +[Get it on Google Play](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. +Collection screenshotImage screenshotStats screenshot + ## Features - support raster images: JPEG, PNG, GIF, WEBP, BMP, WBMP, HEIC (from Android Pie) @@ -48,4 +56,5 @@ If time permits, I intend to eventually add these: Create a file named `/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `/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 From d368fbe65c81bac2eb752dea5c4f05d4ecb242ed Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 01:12:22 +0900 Subject: [PATCH 09/18] API 30: handle access at directory level, request max but can process with min --- .../deckers/thibault/aves/MainActivity.java | 4 +- .../aves/channel/calls/StorageHandler.java | 24 +- .../channel/streams/ImageOpStreamHandler.java | 12 +- .../streams/MediaStoreStreamHandler.java | 10 +- .../streams/StorageAccessStreamHandler.java | 4 +- .../thibault/aves/model/SourceImageEntry.java | 13 +- .../aves/model/provider/ImageProvider.java | 43 ++-- .../provider/MediaStoreImageProvider.java | 60 ++--- .../aves/utils/PermissionManager.java | 129 ++++++++--- .../thibault/aves/utils/StorageUtils.java | 216 ++++++++++++------ lib/services/android_app_service.dart | 2 +- lib/services/android_file_service.dart | 14 +- .../action_delegates/permission_aware.dart | 40 ++-- .../selection_action_delegate.dart | 2 +- lib/widgets/common/app_bar_subtitle.dart | 40 ++-- lib/widgets/debug_page.dart | 46 +--- .../fullscreen/info/location_section.dart | 2 +- lib/widgets/stats/stats.dart | 2 +- 18 files changed, 380 insertions(+), 283 deletions(-) diff --git a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java index e7296457b..f248ce1cf 100644 --- a/android/app/src/main/java/deckers/thibault/aves/MainActivity.java +++ b/android/app/src/main/java/deckers/thibault/aves/MainActivity.java @@ -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, 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, treeUri); + PermissionManager.onPermissionResult(requestCode, treeUri); } } } diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java index 764c033bb..060dc3323 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/StorageHandler.java @@ -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 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> getStorageVolumes() { List> 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 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()); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java index 82f3d3825..c6d3e3fc3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java @@ -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 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) arguments; this.op = (String) argMap.get("op"); @@ -100,7 +100,7 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { } List 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 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); diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java index 2d0dfe633..819f83afb 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.java @@ -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 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 argMap = (Map) arguments; this.knownEntries = (Map) 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(); } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java index 810decc4f..d7dac8d63 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.java @@ -32,8 +32,8 @@ 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, path)); - Runnable onDenied = () -> success(false); + 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); } diff --git a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java index 50067895b..ade906ac4 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/SourceImageEntry.java @@ -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 map) { + public SourceImageEntry(@NonNull Map 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; diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index 34b912559..547f0367d 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -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; @@ -49,15 +48,15 @@ public abstract class ImageProvider { callback.onFailure(new UnsupportedOperationException()); } - public ListenableFuture delete(final Activity activity, final String path, final Uri uri) { + public ListenableFuture 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 entries, @NonNull final ImageOpCallback callback) { + public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List 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; @@ -71,7 +70,7 @@ public abstract class ImageProvider { 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) { @@ -83,27 +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) { + 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; @@ -148,7 +147,7 @@ public abstract class ImageProvider { Map 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) { @@ -163,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; @@ -188,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; @@ -220,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) { @@ -235,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; @@ -267,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()); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index cc7c15401..475cd67b3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -1,7 +1,6 @@ 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; @@ -210,14 +209,14 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public ListenableFuture delete(final Activity activity, final String path, final Uri mediaUri) { + public ListenableFuture delete(final Context context, final String path, final Uri mediaUri) { SettableFuture future = SettableFuture.create(); if (StorageUtils.requireAccessPermission(path)) { // 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 { @@ -230,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")); @@ -242,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) { @@ -260,14 +259,14 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { - DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(activity, destinationDir); + public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List 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; @@ -282,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> 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 newFields = newFieldsFuture.get(); result.put("success", true); @@ -309,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> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, + private ListenableFuture> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri, final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { SettableFuture> future = SettableFuture.create(); @@ -323,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); @@ -363,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> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { + private ListenableFuture> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { SettableFuture> future = SettableFuture.create(); try { @@ -375,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: @@ -393,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); @@ -401,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 newFields) { newFields.put("deletedSource", finalDeletedSource); @@ -430,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; } } diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index cbbf9beac..b7e9cc979 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -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,20 +35,6 @@ public class PermissionManager { // permission request code to pending runnable private static ConcurrentHashMap 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 uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream() - .filter(uriPermission -> uriPermission.getUri().toString().equals(volumeTreeUri)) - .findFirst(); - return uriPermissionOptional.map(UriPermission::getUri).orElse(null); - } - 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)); @@ -64,39 +58,106 @@ public class PermissionManager { ActivityCompat.startActivityForResult(activity, intent, VOLUME_ROOT_PERMISSION_REQUEST_CODE, null); } - public static void onPermissionResult(Activity activity, int requestCode, @Nullable Uri treeUri) { + 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; - if (granted) { - String requestedPath = handler.path; - if (isTreeUriPath(requestedPath, treeUri)) { - StorageUtils.setVolumeTreeUri(activity, requestedPath, treeUri.toString()); - } else { - granted = false; - } - } - Runnable runnable = granted ? handler.onGranted : handler.onDenied; if (runnable == null) return; runnable.run(); } - private static boolean isTreeUriPath(String path, Uri treeUri) { - // TODO TLAD check requestedPath match treeUri - // e.g. OK match for path=/storage/emulated/0/, treeUri=content://com.android.externalstorage.documents/tree/primary%3A - // e.g. NO match for path=/storage/10F9-3F13/, treeUri=content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures - Log.d(LOG_TAG, "isTreeUriPath path=" + path + ", treeUri=" + treeUri); - return true; + public static Optional getGrantedDirForPath(@NonNull Context context, @NonNull String anyPath) { + return getGrantedDirs(context).stream().filter(anyPath::startsWith).findFirst(); + } + + public static List> getInaccessibleDirectories(@NonNull Context context, @NonNull List dirPaths) { + Set grantedDirs = getGrantedDirs(context); + + // find set of inaccessible directories for each volume + Map> 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 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 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> inaccessibleDirs = new ArrayList<>(); + StorageManager sm = context.getSystemService(StorageManager.class); + if (sm != null) { + for (Map.Entry> 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 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 getGrantedDirs(Context context) { + HashSet accessibleDirs = new HashSet<>(); + + // find paths matching URIs granted by the user + for (UriPermission uriPermission : context.getContentResolver().getPersistedUriPermissions()) { + Optional 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; - final Runnable onDenied; + 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; diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java index 378720aac..b0a814ab3 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/StorageUtils.java @@ -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,13 +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.List; -import java.util.Map; 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); @@ -52,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 getVolumePath(Context context, @NonNull String anyPath) { + public static Optional getVolumePath(@NonNull Context context, @NonNull String anyPath) { return Arrays.stream(getVolumePaths(context)).filter(anyPath::startsWith).findFirst(); } @Nullable - private static Iterator getPathStepIterator(Context context, @NonNull String anyPath) { - Optional volumePathOpt = getVolumePath(context, anyPath); - if (!volumePathOpt.isPresent()) return null; + private static Iterator 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; @@ -134,7 +134,7 @@ public class StorageUtils { Log.e(LOG_TAG, "insomnia", e); } } - } while(!validFiles); + } while (!validFiles); for (File file : files) { String applicationSpecificAbsolutePath = file.getAbsolutePath(); String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data")); @@ -225,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 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 getVolumeTreeUris(Activity activity) { - Map 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 iterator = jsonObject.keys(); - while (iterator.hasNext()) { - String k = iterator.next(); - String v = (String) jsonObject.get(k); - map.put(k, v); + private static Optional 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 getVolumeTreeUriForPath(Activity activity, String anyPath) { - return StorageUtils.getVolumePath(activity, anyPath).map(volumePath -> getVolumeTreeUris(activity).get(volumePath)); + private static Optional 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 convertDirPathToTreeUri(@NonNull Context context, @NonNull String dirPath) { + Optional 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 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 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(); } /** @@ -269,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 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)); @@ -290,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 pathIterator = getPathStepIterator(activity, directoryPath); + Iterator pathIterator = getPathStepIterator(context, dirPath, grantedDir); while (pathIterator != null && pathIterator.hasNext()) { String dirName = pathIterator.next(); DocumentFileCompat dirFile = findDocumentFileIgnoreCase(parentFile, dirName); @@ -319,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; } } @@ -338,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 getDocumentFileFromVolumeTree(Context context, Uri rootTreeUri, String path) { - if (rootTreeUri == null || path == null) { - return Optional.empty(); - } - + private static Optional 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 pathIterator = getPathStepIterator(context, path); + Iterator pathIterator = getPathStepIterator(context, anyPath, null); while (pathIterator != null && pathIterator.hasNext()) { documentFile = findDocumentFileIgnoreCase(documentFile, pathIterator.next()); if (documentFile == null) { @@ -393,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)) { @@ -403,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) { @@ -414,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); + } + } + } } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index ab4672036..8d189505e 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -88,7 +88,7 @@ class AndroidAppService { } } - static Future share(Set entries) async { + static Future share(Iterable 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(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); diff --git a/lib/services/android_file_service.dart b/lib/services/android_file_service.dart index 078e38cca..2eda39d07 100644 --- a/lib/services/android_file_service.dart +++ b/lib/services/android_file_service.dart @@ -18,16 +18,18 @@ class AndroidFileService { return []; } - static Future requireVolumeAccessDialog(String path) async { + // returns a list of directories, + // each directory is a map with "volumePath", "volumeDescription", "relativeDir" + static Future> getInaccessibleDirectories(Iterable dirPaths) async { try { - final result = await platform.invokeMethod('requireVolumeAccessDialog', { - 'path': path, + final result = await platform.invokeMethod('getInaccessibleDirectories', { + 'dirPaths': dirPaths.toList(), }); - return result as bool; + return (result as List).cast(); } 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` diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index d22d93d52..9b1f6f886 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -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 checkStoragePermission(BuildContext context, Iterable 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 checkStoragePermissionForPaths(BuildContext context, Iterable paths) async { - final volumes = paths.map(androidFileUtils.getStorageVolume).toSet(); - final ungrantedVolumes = (await Future.wait>( - 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 checkStoragePermissionForAlbums(BuildContext context, Set 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( context: context, builder: (BuildContext 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.'), + 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; } } diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index c2ecf39fd..e4f51b21e 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -100,7 +100,7 @@ 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; diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 0b1f31ce0..66fc29dea 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -34,8 +34,8 @@ class SourceStateAwareAppBarTitle extends StatelessWidget { child: sourceState == SourceState.ready ? const SizedBox.shrink() : SourceStateSubtitle( - source: source, - ), + source: source, + ), ); }, ), @@ -70,24 +70,24 @@ class SourceStateSubtitle extends StatelessWidget { return subtitle == null ? const SizedBox.shrink() : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: subtitleStyle), - StreamBuilder( - 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( + 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), + ), + ); + }, ), - ); - }, - ), - ], - ); + ], + ); } } diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index c18f8f8a3..dee6a0873 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -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 { Future> _dbMetadataLoader; Future> _dbAddressLoader; Future> _dbFavouritesLoader; - Future>> _volumePermissionLoader; Future _envLoader; List get entries => widget.source.rawEntries; @@ -44,13 +41,6 @@ class DebugPageState extends State { void initState() { super.initState(); _startDbReport(); - _volumePermissionLoader = Future.wait>( - androidFileUtils.storageVolumes.map( - (volume) => AndroidFileService.requireVolumeAccessDialog(volume.path).then( - (value) => Tuple2(volume.path, !value), - ), - ), - ); _envLoader = AndroidAppService.getEnv(); } @@ -299,31 +289,17 @@ class DebugPageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - FutureBuilder( - future: _volumePermissionLoader, - builder: (context, AsyncSnapshot>> 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}', + }), + const Divider(), + ]) ], ); } diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index c0da8ccc8..c7c4cbbc3 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -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'; diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 7decb89bb..e7ae91d4a 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -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'; From bcc571fa8463df1fd588cd68cc7c20b3efd7ad6d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 01:21:58 +0900 Subject: [PATCH 10/18] launch: do not try to catalogue SVGs --- lib/model/source/tag.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 48eecace8..5b2c5a1c4 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -23,7 +23,7 @@ mixin TagMixin on SourceBase { Future 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; From 2ed7851d7d3d187b12cad8b1cbbbb1c8458ecc13 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 01:35:13 +0900 Subject: [PATCH 11/18] selection: toggle section selection by tapping header --- lib/widgets/album/grid/header_generic.dart | 77 ++++++++++++---------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart index f30ff4554..a27efd580 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/album/grid/header_generic.dart @@ -129,50 +129,67 @@ class TitleSectionHeader extends StatelessWidget { 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) + 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(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; @@ -199,13 +216,7 @@ class SectionSelectableLeading extends StatelessWidget { padding: const 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( minHeight: leadingDimension, From a48937795e34823c83b89ee2cbbeb788311cf460 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 02:21:51 +0900 Subject: [PATCH 12/18] fixed some lint issues (effective dart style) --- lib/model/filters/mime.dart | 2 +- lib/model/filters/query.dart | 4 ++-- lib/model/image_entry.dart | 8 +++---- lib/model/mime_types.dart | 24 +++++++++---------- lib/model/source/collection_lens.dart | 4 ++-- lib/model/source/location.dart | 2 +- lib/services/image_file_service.dart | 4 ++-- lib/services/metadata_service.dart | 2 +- lib/utils/durations.dart | 2 +- lib/widgets/album/app_bar.dart | 2 +- lib/widgets/album/grid/scaling.dart | 2 +- lib/widgets/album/search/search_delegate.dart | 8 +++---- lib/widgets/album/thumbnail_collection.dart | 2 +- lib/widgets/app_drawer.dart | 2 +- .../entry_action_delegate.dart | 2 +- .../action_delegates/permission_aware.dart | 2 +- .../selection_action_delegate.dart | 14 +++++------ .../media_store_collection_provider.dart | 2 +- .../image_providers/thumbnail_provider.dart | 2 +- lib/widgets/common/scroll_thumb.dart | 8 +------ lib/widgets/common/transition_image.dart | 2 +- lib/widgets/debug_page.dart | 2 +- lib/widgets/fullscreen/image_view.dart | 2 +- .../fullscreen/info/basic_section.dart | 2 +- lib/widgets/fullscreen/info/info_page.dart | 2 +- lib/widgets/fullscreen/overlay/top.dart | 2 +- lib/widgets/fullscreen/overlay/video.dart | 10 ++++---- lib/widgets/home_page.dart | 2 +- lib/widgets/stats/filter_table.dart | 2 +- lib/widgets/stats/stats.dart | 2 +- lib/widgets/welcome_page.dart | 2 +- 31 files changed, 61 insertions(+), 67 deletions(-) diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 1c2004eca..55db249a1 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -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(); diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 90d35a1ea..1cd687e9d 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -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); diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 8c62c9d2d..76c1fa58a 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -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> call() => Geocoder.local.findAddressesFromCoordinates(coordinates); final addresses = await (background ? servicePolicy.call( call, diff --git a/lib/model/mime_types.dart b/lib/model/mime_types.dart index f84af8658..f11273d0e 100644 --- a/lib/model/mime_types.dart +++ b/lib/model/mime_types.dart @@ -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'; } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 9fe1b0e55..565847ee5 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -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(_filteredEntries, (entry) => entry.directory); + int compare(a, b) { final ua = source.getUniqueAlbumName(a); final ub = source.getUniqueAlbumName(b); final c = compareAsciiUpperCase(ua, ub); diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 678351b75..0d60fa61d 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -75,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.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); + List lister(String Function(AddressDetails a) f) => List.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); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index a2ffda260..94412adf3 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -140,7 +140,7 @@ class ImageFileService { try { return opChannel.receiveBroadcastStream({ '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({ 'op': 'move', - 'entries': entries.map((entry) => _toPlatformEntryMap(entry)).toList(), + 'entries': entries.map(_toPlatformEntryMap).toList(), 'copy': copy, 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index eb571e857..f17c184de 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -26,7 +26,7 @@ class MetadataService { static Future getCatalogMetadata(ImageEntry entry, {bool background = false}) async { if (entry.isSvg) return null; - final call = () async { + Future call() async { try { // return map with: // 'mimeType': MIME type as reported by metadata extractors, not Media Store (string) diff --git a/lib/utils/durations.dart b/lib/utils/durations.dart index 9848ebf12..b397a0c2d 100644 --- a/lib/utils/durations.dart +++ b/lib/utils/durations.dart @@ -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; } diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 41464149d..949947297 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -155,7 +155,7 @@ class _CollectionAppBarState extends State 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')); }, ); } diff --git a/lib/widgets/album/grid/scaling.dart b/lib/widgets/album/grid/scaling.dart index cf73d55e8..17869db62 100644 --- a/lib/widgets/album/grid/scaling.dart +++ b/lib/widgets/album/grid/scaling.dart @@ -62,7 +62,7 @@ class _GridScaleGestureDetectorState extends State { scrollableBox.hitTest(result, position: details.localFocalPoint); // find `RenderObject`s at the gesture focal point - final firstOf = (BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T; + T firstOf(BoxHitTestResult result) => result.path.firstWhere((el) => el.target is T, orElse: () => null)?.target as T; final renderMetaData = firstOf(result); // abort if we cannot find an image to show on overlay if (renderMetaData == null) return; diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index d770779cc..7adb9bb2e 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -57,7 +57,7 @@ class ImageSearchDelegate extends SearchDelegate { @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( valueListenable: expandedSectionNotifier, @@ -70,10 +70,10 @@ class ImageSearchDelegate extends SearchDelegate { 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( diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index 8d0a417e7..a6883a415 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -220,7 +220,7 @@ class _CollectionScrollViewState extends State { ); } debugPrint('collection.filters=${collection.filters}'); - if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.ANY_VIDEO)) { + if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) { return const EmptyContent( icon: AIcons.video, text: 'No videos', diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index ecd07614a..0f5da71f0 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -85,7 +85,7 @@ class _AppDrawerState extends State { source: source, leading: const Icon(AIcons.video), title: 'Videos', - filter: MimeFilter(MimeTypes.ANY_VIDEO), + filter: MimeFilter(MimeTypes.anyVideo), ); final favouriteEntry = _FilteredCollectionNavTile( source: source, diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index 56fc62b2a..29eb3a72e 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -118,7 +118,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { Future _showDeleteDialog(BuildContext context, ImageEntry entry) async { final confirmed = await showDialog( context: context, - builder: (BuildContext context) { + builder: (context) { return AlertDialog( content: const Text('Are you sure?'), actions: [ diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index 9b1f6f886..62d4d0ca0 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -21,7 +21,7 @@ mixin PermissionAwareMixin { final confirmed = await showDialog( context: context, - builder: (BuildContext context) { + builder: (context) { return AlertDialog( title: const 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.'), diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index e4f51b21e..0637168c5 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -116,10 +116,10 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { 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 = {}; @@ -187,9 +187,9 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { final confirmed = await showDialog( 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), @@ -217,7 +217,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { 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 onComplete() => _hideOpReportOverlay().then((_) => onDone(processed)); opStream.listen( - (event) => processed.add(event), + processed.add, onError: (error) { debugPrint('_showOpReport error=$error'); onComplete(); diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/widgets/common/data_providers/media_store_collection_provider.dart index 7ce1a5520..2585181c4 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/widgets/common/data_providers/media_store_collection_provider.dart @@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource { var refreshCount = 10; const refreshCountMax = 1000; final allNewEntries = [], pendingNewEntries = []; - final addPendingEntries = () { + void addPendingEntries() { allNewEntries.addAll(pendingNewEntries); addAll(pendingNewEntries); pendingNewEntries.clear(); diff --git a/lib/widgets/common/image_providers/thumbnail_provider.dart b/lib/widgets/common/image_providers/thumbnail_provider.dart index 5b3164df6..53b718075 100644 --- a/lib/widgets/common/image_providers/thumbnail_provider.dart +++ b/lib/widgets/common/image_providers/thumbnail_provider.dart @@ -54,7 +54,7 @@ class ThumbnailProvider extends ImageProvider { } @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); } diff --git a/lib/widgets/common/scroll_thumb.dart b/lib/widgets/common/scroll_thumb.dart index 0c21bb256..cc2b9bcec 100644 --- a/lib/widgets/common/scroll_thumb.dart +++ b/lib/widgets/common/scroll_thumb.dart @@ -32,13 +32,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({ clipper: ArrowClipper(), ), ); - return ( - Color backgroundColor, - Animation thumbAnimation, - Animation labelAnimation, - double height, { - Widget labelText, - }) { + return (backgroundColor, thumbAnimation, labelAnimation, height, {labelText}) { return DraggableScrollbar.buildScrollThumbAndLabel( scrollThumb: scrollThumb, backgroundColor: backgroundColor, diff --git a/lib/widgets/common/transition_image.dart b/lib/widgets/common/transition_image.dart index 3eb9ad165..1f1117206 100644 --- a/lib/widgets/common/transition_image.dart +++ b/lib/widgets/common/transition_image.dart @@ -12,7 +12,7 @@ class TransitionImage extends StatefulWidget { final ImageProvider image; final double width, height; final ValueListenable animation; - final gaplessPlayback = false; + final bool gaplessPlayback = false; const TransitionImage({ @required this.image, diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index dee6a0873..a05ead50b 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -135,7 +135,7 @@ class DebugPageState extends State { ), const SizedBox(width: 8), RaisedButton( - onPressed: () => ImageFileService.clearSizedThumbnailDiskCache(), + onPressed: ImageFileService.clearSizedThumbnailDiskCache, child: const Text('Clear'), ), ], diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 86febbb54..25812a156 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -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 diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index ffb00956d..24888eeb8 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -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)), diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index 478c0703f..dcc7b2229 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -165,7 +165,7 @@ class SectionRow extends StatelessWidget { @override Widget build(BuildContext context) { const dim = 32.0; - final buildDivider = () => const SizedBox( + Widget buildDivider() => const SizedBox( width: dim, child: Divider( thickness: AvesFilterChip.outlineWidth, diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 9183c7d1a..662fd9606 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -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( diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 2dbe893da..5cfaba055 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -98,7 +98,7 @@ class VideoControlOverlayState extends State 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((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); final mqWidth = mq.item1; final mqViewInsets = mq.item2; final mqViewPadding = mq.item3; @@ -164,17 +164,17 @@ class VideoControlOverlayState extends State 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( diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index c57e6dbf6..33f715f1b 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -68,7 +68,7 @@ class _HomePageState extends State { // 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; } } diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 6234c72c7..f3b306413 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -67,7 +67,7 @@ class FilterTable extends StatelessWidget { center: Text(NumberFormat.percentPattern().format(percent)), ), Text( - '${count}', + '$count', style: const TextStyle(color: Colors.white70), textAlign: TextAlign.end, ), diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index e7ae91d4a..b5e2d687f 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -158,7 +158,7 @@ class StatsPage extends StatelessWidget { ), Center( child: Text( - '${sum}\n${label(sum)}', + '$sum\n${label(sum)}', textAlign: TextAlign.center, ), ), diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 5d6f8e34e..31ce653f6 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -35,7 +35,7 @@ class _WelcomePageState extends State { padding: const EdgeInsets.all(16.0), child: FutureBuilder( future: _termsLoader, - builder: (BuildContext context, AsyncSnapshot snapshot) { + builder: (context, AsyncSnapshot snapshot) { if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final terms = snapshot.data; return Column( From 4cbfcdc2e3aed69294c0f08a4a672647b66e548a Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 03:03:07 +0900 Subject: [PATCH 13/18] fixed more lint issues (effective dart style) --- analysis_options.yaml | 25 ++++++++++++--- lib/main.dart | 4 +-- lib/model/image_metadata.dart | 3 +- lib/model/settings.dart | 1 + lib/model/source/collection_lens.dart | 2 +- lib/services/image_file_service.dart | 5 +-- lib/utils/color_utils.dart | 2 +- .../album/grid/list_section_layout.dart | 20 ------------ lib/widgets/album/thumbnail/overlay.dart | 2 +- .../selection_action_delegate.dart | 8 ++--- lib/widgets/common/aves_filter_chip.dart | 4 +-- lib/widgets/debug_page.dart | 32 +++++++++---------- lib/widgets/fullscreen/debug.dart | 16 +++++----- lib/widgets/fullscreen/info/info_page.dart | 2 +- lib/widgets/fullscreen/overlay/bottom.dart | 4 +-- lib/widgets/fullscreen/overlay/video.dart | 2 +- lib/widgets/home_page.dart | 4 +-- lib/widgets/stats/stats.dart | 4 +-- lib/widgets/welcome_page.dart | 4 +-- 19 files changed, 72 insertions(+), 72 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 157ed9498..780713728 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,9 +4,26 @@ 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 + avoid_function_literals_in_foreach_calls: false # benefit? + lines_longer_than_80_chars: false # nope + + avoid_classes_with_only_static_members: false # maybe? + prefer_relative_imports: false # check IDE support (auto import, file move) + public_member_api_docs: false # maybe? + + always_declare_return_types: true + avoid_types_on_closure_parameters: true + constant_identifier_names: true + prefer_const_constructors: true + prefer_const_constructors_in_immutables: true + prefer_const_declarations: true + prefer_function_declarations_over_variables: true + prefer_interpolation_to_compose_strings: true + unnecessary_brace_in_string_interps: true + unnecessary_lambdas: true diff --git a/lib/main.dart b/lib/main.dart index 8d8f4722c..6216fdcf0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -58,9 +58,9 @@ class _AvesAppState extends State { ), ), ), - home: FutureBuilder( + home: FutureBuilder( future: _appSetup, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return const Icon(AIcons.error); if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); return settings.hasAcceptedTerms ? const HomePage() : const WelcomePage(); diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index fae1031d6..7c7e32490 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -179,11 +179,12 @@ class AddressDetails { } } +@immutable class FavouriteRow { final int contentId; final String path; - FavouriteRow({ + const FavouriteRow({ this.contentId, this.path, }); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 0cf95928c..7de40ad4c 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -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(String key, T defaultValue, Iterable values) { diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 565847ee5..75c0a0d3f 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -168,7 +168,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel ]); break; case SortFactor.name: - final byAlbum = groupBy(_filteredEntries, (entry) => entry.directory); + final byAlbum = groupBy(_filteredEntries, (entry) => entry.directory); int compare(a, b) { final ua = source.getUniqueAlbumName(a); final ub = source.getUniqueAlbumName(b); diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 94412adf3..bd3135273 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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, diff --git a/lib/utils/color_utils.dart b/lib/utils/color_utils.dart index 8a22b11fe..d9842ead6 100644 --- a/lib/utils/color_utils.dart +++ b/lib/utils/color_utils.dart @@ -5,7 +5,7 @@ final Map _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(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; diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart index f9eec6a7a..6b7aef300 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -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'; @@ -145,25 +144,6 @@ class SectionedListLayout { final top = sectionLayout.indexToLayoutOffset(listIndex); return Rect.fromLTWH(left, top, tileExtent, tileExtent); } - - int rowIndex(dynamic sectionKey, List 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 { diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/album/thumbnail/overlay.dart index a29f8bc72..8b839d9c4 100644 --- a/lib/widgets/album/thumbnail/overlay.dart +++ b/lib/widgets/album/thumbnail/overlay.dart @@ -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(context); diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index 0637168c5..e263f58b0 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -105,11 +105,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { final selection = collection.selection.toList(); if (!await checkStoragePermission(context, selection)) return; - _showOpReport( + _showOpReport( context: context, selection: selection, opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum), - onDone: (Set processed) async { + onDone: (processed) async { debugPrint('$runtimeType _moveSelection onDone'); final movedOps = processed.where((e) => e.success); final movedCount = movedOps.length; @@ -207,11 +207,11 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { if (!await checkStoragePermission(context, selection)) return; - _showOpReport( + _showOpReport( context: context, selection: selection, opStream: ImageFileService.delete(selection), - onDone: (Set processed) { + onDone: (processed) { final deletedUris = processed.where((e) => e.success).map((e) => e.uri); final deletedCount = deletedUris.length; final selectionCount = selection.length; diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index 19adca22d..8cce670b0 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -157,9 +157,9 @@ class _AvesFilterChipState extends State { } : null, borderRadius: borderRadius, - child: FutureBuilder( + child: FutureBuilder( future: _colorFuture, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { final outlineColor = snapshot.hasData ? snapshot.data : Colors.transparent; return DecoratedBox( decoration: BoxDecoration( diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index a05ead50b..736151d48 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -134,16 +134,16 @@ class DebugPageState extends State { child: Text('Glide disk cache: ?'), ), const SizedBox(width: 8), - RaisedButton( + const RaisedButton( onPressed: ImageFileService.clearSizedThumbnailDiskCache, - child: const Text('Clear'), + child: Text('Clear'), ), ], ), const Divider(), - FutureBuilder( + FutureBuilder( future: _dbFileSizeLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -160,9 +160,9 @@ class DebugPageState extends State { ); }, ), - FutureBuilder( + FutureBuilder( future: _dbEntryLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -179,9 +179,9 @@ class DebugPageState extends State { ); }, ), - FutureBuilder( + FutureBuilder( future: _dbDateLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -198,9 +198,9 @@ class DebugPageState extends State { ); }, ), - FutureBuilder( + FutureBuilder( future: _dbMetadataLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -217,9 +217,9 @@ class DebugPageState extends State { ); }, ), - FutureBuilder( + FutureBuilder( future: _dbAddressLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -236,9 +236,9 @@ class DebugPageState extends State { ); }, ), - FutureBuilder( + FutureBuilder( future: _dbFavouritesLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); return Row( @@ -308,9 +308,9 @@ class DebugPageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - FutureBuilder( + FutureBuilder( future: _envLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 091ff4d5a..0ebf2129e 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -62,9 +62,9 @@ class _FullscreenDebugPageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - FutureBuilder( + FutureBuilder( future: _dbDateLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final data = snapshot.data; @@ -81,9 +81,9 @@ class _FullscreenDebugPageState extends State { }, ), const SizedBox(height: 16), - FutureBuilder( + FutureBuilder( future: _dbMetadataLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final data = snapshot.data; @@ -107,9 +107,9 @@ class _FullscreenDebugPageState extends State { }, ), const SizedBox(height: 16), - FutureBuilder( + FutureBuilder( future: _dbAddressLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final data = snapshot.data; @@ -155,9 +155,9 @@ class _FullscreenDebugPageState extends State { return ListView( padding: const EdgeInsets.all(16), children: [ - FutureBuilder( + FutureBuilder( future: _contentResolverMetadataLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final data = SplayTreeMap.of(snapshot.data.map((k, v) { diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index dcc7b2229..e846ff362 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -60,7 +60,7 @@ class InfoPageState extends State { final mqViewInsetsBottom = mq.item2; final split = mqWidth > 400; - return ValueListenableBuilder( + return ValueListenableBuilder( valueListenable: widget.entryNotifier, builder: (context, entry, child) { final locationAtTop = split && entry.hasGps; diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index d4b8c0264..a3e21c8f6 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -82,9 +82,9 @@ class _FullscreenBottomOverlayState extends State { return Container( color: FullscreenOverlay.backgroundColor, padding: viewInsets + viewPadding.copyWith(top: 0), - child: FutureBuilder( + child: FutureBuilder( future: _detailLoader, - builder: (futureContext, AsyncSnapshot snapshot) { + builder: (futureContext, snapshot) { if (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) { _lastDetails = snapshot.data; _lastEntry = entry; diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index 5cfaba055..a1b6d2270 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -98,7 +98,7 @@ class VideoControlOverlayState extends State with SingleTic @override Widget build(BuildContext context) { - final mq = context.select((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); + final mq = context.select>((mq) => Tuple3(mq.size.width, mq.viewInsets, mq.viewPadding)); final mqWidth = mq.item1; final mqViewInsets = mq.item2; final mqViewPadding = mq.item3; diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 33f715f1b..1444bc71b 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -92,9 +92,9 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - return FutureBuilder( + return FutureBuilder( future: _appSetup, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError) return const Icon(AIcons.error); if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); if (AvesApp.mode == AppMode.view) { diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index b5e2d687f..ef5be10c1 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -54,7 +54,7 @@ class StatsPage extends StatelessWidget { text: 'No images', ); } else { - final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((k, v) => MapEntry(k, v.length)); + final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((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( @@ -120,7 +120,7 @@ class StatsPage extends StatelessWidget { Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map byMimeTypes) { if (byMimeTypes.isEmpty) return const SizedBox.shrink(); - final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); + final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); final seriesData = byMimeTypes.entries.map((kv) => StringNumDatum(_cleanMime(kv.key), kv.value)).toList(); seriesData.sort((kv1, kv2) { diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 31ce653f6..9485b0d89 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -33,9 +33,9 @@ class _WelcomePageState extends State { child: Container( alignment: Alignment.center, padding: const EdgeInsets.all(16.0), - child: FutureBuilder( + child: FutureBuilder( future: _termsLoader, - builder: (context, AsyncSnapshot snapshot) { + builder: (context, snapshot) { if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); final terms = snapshot.data; return Column( From c7670b9ccf18abd078c6838c3ce0aed606e79c90 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 10:52:29 +0900 Subject: [PATCH 14/18] CI: added static analysis, restored test step --- .github/workflows/main.yml | 8 +++++--- analysis_options.yaml | 14 +++++++++----- test/widget_test.dart | 8 ++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 test/widget_test.dart diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 46752b1c5..bbf443a5f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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: diff --git a/analysis_options.yaml b/analysis_options.yaml index 780713728..88c48c0b9 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,20 +10,24 @@ analyzer: linter: rules: + # 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 - avoid_classes_with_only_static_members: false # maybe? + # from 'effective dart', undecided prefer_relative_imports: false # check IDE support (auto import, file move) public_member_api_docs: false # maybe? - always_declare_return_types: true + # from 'effective dart', included avoid_types_on_closure_parameters: true constant_identifier_names: true - prefer_const_constructors: true - prefer_const_constructors_in_immutables: true - prefer_const_declarations: 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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 000000000..abe19491a --- /dev/null +++ b/test/widget_test.dart @@ -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()); + }); +} From d715d628be8169f37943fab8beaf4fe9f2f05c43 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 12:14:43 +0900 Subject: [PATCH 15/18] removed noisy `const` --- lib/main.dart | 10 +-- lib/model/source/collection_lens.dart | 2 +- lib/model/source/collection_source.dart | 2 +- lib/services/metadata_service.dart | 2 +- lib/utils/time_utils.dart | 2 +- lib/widgets/about/about_page.dart | 10 +-- lib/widgets/about/licenses.dart | 16 ++-- lib/widgets/album/app_bar.dart | 26 +++--- lib/widgets/album/empty.dart | 4 +- lib/widgets/album/filter_bar.dart | 8 +- lib/widgets/album/grid/header_album.dart | 2 +- lib/widgets/album/grid/header_generic.dart | 10 +-- .../album/grid/list_section_layout.dart | 2 +- lib/widgets/album/grid/list_sliver.dart | 2 +- lib/widgets/album/grid/scaling.dart | 2 +- .../album/search/expandable_filter_row.dart | 12 +-- lib/widgets/album/search/search_delegate.dart | 6 +- lib/widgets/album/thumbnail/overlay.dart | 4 +- lib/widgets/album/thumbnail/raster.dart | 4 +- lib/widgets/album/thumbnail_collection.dart | 14 ++-- lib/widgets/app_drawer.dart | 50 ++++++------ .../action_delegates/create_album_dialog.dart | 10 +-- .../entry_action_delegate.dart | 2 +- .../common/action_delegates/feedback.dart | 2 +- .../action_delegates/permission_aware.dart | 2 +- .../selection_action_delegate.dart | 6 +- lib/widgets/common/app_bar_subtitle.dart | 8 +- lib/widgets/common/aves_filter_chip.dart | 14 ++-- .../media_store_collection_provider.dart | 2 +- lib/widgets/common/icons.dart | 6 +- lib/widgets/common/link_chip.dart | 8 +- lib/widgets/common/menu_row.dart | 6 +- lib/widgets/common/scroll_thumb.dart | 8 +- lib/widgets/debug_page.dart | 80 +++++++++---------- lib/widgets/filter_grid_page.dart | 10 +-- lib/widgets/fullscreen/debug.dart | 22 ++--- lib/widgets/fullscreen/fullscreen_body.dart | 8 +- lib/widgets/fullscreen/image_page.dart | 2 +- lib/widgets/fullscreen/image_view.dart | 6 +- .../fullscreen/info/basic_section.dart | 4 +- lib/widgets/fullscreen/info/info_page.dart | 18 ++--- .../fullscreen/info/location_section.dart | 18 ++--- .../fullscreen/info/metadata_section.dart | 12 +-- lib/widgets/fullscreen/overlay/bottom.dart | 24 +++--- lib/widgets/fullscreen/overlay/top.dart | 20 ++--- lib/widgets/fullscreen/overlay/video.dart | 12 +-- lib/widgets/fullscreen/video_view.dart | 6 +- lib/widgets/home_page.dart | 6 +- lib/widgets/stats/filter_table.dart | 8 +- lib/widgets/stats/stats.dart | 20 ++--- lib/widgets/welcome_page.dart | 24 +++--- 51 files changed, 282 insertions(+), 282 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6216fdcf0..da0657f6a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -45,10 +45,10 @@ class _AvesAppState extends State { 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, @@ -61,9 +61,9 @@ class _AvesAppState extends State { home: FutureBuilder( future: _appSetup, builder: (context, snapshot) { - if (snapshot.hasError) return const Icon(AIcons.error); - if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); - return settings.hasAcceptedTerms ? const HomePage() : const WelcomePage(); + if (snapshot.hasError) return Icon(AIcons.error); + if (snapshot.connectionState != ConnectionState.done) return Scaffold(); + return settings.hasAcceptedTerms ? HomePage() : WelcomePage(); }, ), ); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 75c0a0d3f..7ba050181 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -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; } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index a073a87e1..bf56f6797 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -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 entries) { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index f17c184de..cbee8a679 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -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, diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index 46ae6835f..7d65020c2 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -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()); diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index a0708ee8b..8ac977f39 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -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 { children: [ WidgetSpan( child: Padding( - padding: const EdgeInsetsDirectional.only(end: 4), + padding: EdgeInsetsDirectional.only(end: 4), child: FlutterLogo( size: style.fontSize * 1.25, ), diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 9c597d937..e8241cc70 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -39,7 +39,7 @@ class _LicensesState extends State { @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 { 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 { 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, diff --git a/lib/widgets/album/app_bar.dart b/lib/widgets/album/app_bar.dart index 949947297..a2086dee7 100644 --- a/lib/widgets/album/app_bar.dart +++ b/lib/widgets/album/app_bar.dart @@ -144,7 +144,7 @@ class _CollectionAppBarState extends State 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, @@ -166,7 +166,7 @@ class _CollectionAppBarState extends State 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 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 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 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 with SingleTickerPr value: CollectionAction.groupByDay, child: MenuRow(text: 'Group by day', checked: collection.groupFactor == GroupFactor.day), ), - const PopupMenuDivider(), + PopupMenuDivider(), ] : []; } diff --git a/lib/widgets/album/empty.dart b/lib/widgets/album/empty.dart index e4bce869f..5c8db758d 100644 --- a/lib/widgets/album/empty.dart +++ b/lib/widgets/album/empty.dart @@ -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', diff --git a/lib/widgets/album/filter_bar.dart b/lib/widgets/album/filter_bar.dart index 62e97b485..53417a632 100644 --- a/lib/widgets/album/filter_bar.dart +++ b/lib/widgets/album/filter_bar.dart @@ -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 { 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 { Padding _buildChip(CollectionFilter filter) { return Padding( - padding: const EdgeInsets.only(right: 8), + padding: EdgeInsets.only(right: 8), child: Center( child: AvesFilterChip( key: ValueKey(filter), diff --git a/lib/widgets/album/grid/header_album.dart b/lib/widgets/album/grid/header_album.dart index 93c371343..249a016d4 100644 --- a/lib/widgets/album/grid/header_album.dart +++ b/lib/widgets/album/grid/header_album.dart @@ -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), diff --git a/lib/widgets/album/grid/header_generic.dart b/lib/widgets/album/grid/header_generic.dart index a27efd580..e10d7b1ea 100644 --- a/lib/widgets/album/grid/header_generic.dart +++ b/lib/widgets/album/grid/header_generic.dart @@ -54,7 +54,7 @@ class SectionHeader extends StatelessWidget { height: height, child: header, ) - : const SizedBox.shrink(); + : SizedBox.shrink(); } Widget _buildAlbumSectionHeader() { @@ -128,7 +128,7 @@ class TitleSectionHeader extends StatelessWidget { return Container( alignment: AlignmentDirectional.centerStart, padding: padding, - constraints: const BoxConstraints(minHeight: leadingDimension), + constraints: BoxConstraints(minHeight: leadingDimension), child: GestureDetector( onTap: () => _toggleSectionSelection(context), child: Text.rich( @@ -213,12 +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: onPressed, tooltip: selected ? 'Deselect section' : 'Select section', - constraints: const BoxConstraints( + constraints: BoxConstraints( minHeight: leadingDimension, minWidth: leadingDimension, ), @@ -236,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, diff --git a/lib/widgets/album/grid/list_section_layout.dart b/lib/widgets/album/grid/list_section_layout.dart index 6b7aef300..b2475c810 100644 --- a/lib/widgets/album/grid/list_section_layout.dart +++ b/lib/widgets/album/grid/list_section_layout.dart @@ -109,7 +109,7 @@ class SectionedListLayoutProvider extends StatelessWidget { sectionKey: sectionKey, height: headerExtent, ) - : const SizedBox.shrink(); + : SizedBox.shrink(); } } diff --git a/lib/widgets/album/grid/list_sliver.dart b/lib/widgets/album/grid/list_sliver.dart index aea48ce9d..97fff2258 100644 --- a/lib/widgets/album/grid/list_sliver.dart +++ b/lib/widgets/album/grid/list_sliver.dart @@ -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, diff --git a/lib/widgets/album/grid/scaling.dart b/lib/widgets/album/grid/scaling.dart index 17869db62..0a6397fd7 100644 --- a/lib/widgets/album/grid/scaling.dart +++ b/lib/widgets/album/grid/scaling.dart @@ -192,7 +192,7 @@ class _ScaleOverlayState extends State { ], ), ) - : const BoxDecoration( + : BoxDecoration( // provide dummy gradient to lerp to the other one during animation gradient: RadialGradient( colors: [ diff --git a/lib/widgets/album/search/expandable_filter_row.dart b/lib/widgets/album/search/expandable_filter_row.dart index d99491ba1..f5ca689bf 100644 --- a/lib/widgets/album/search/expandable_filter_row.dart +++ b/lib/widgets/album/search/expandable_filter_row.dart @@ -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, ), ); diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index 7adb9bb2e..3618c8822 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -44,7 +44,7 @@ class ImageSearchDelegate extends SearchDelegate { return [ if (query.isNotEmpty) IconButton( - icon: const Icon(AIcons.clear), + icon: Icon(AIcons.clear), onPressed: () { query = ''; showSuggestions(context); @@ -63,7 +63,7 @@ class ImageSearchDelegate extends SearchDelegate { valueListenable: expandedSectionNotifier, builder: (context, expandedSection, child) { return ListView( - padding: const EdgeInsets.only(top: 8), + padding: EdgeInsets.only(top: 8), children: [ _buildFilterRow( context: context, @@ -135,7 +135,7 @@ class ImageSearchDelegate extends SearchDelegate { // and possibly trigger a rebuild here _select(context, _buildQueryFilter(true)); }); - return const SizedBox.shrink(); + return SizedBox.shrink(); } QueryFilter _buildQueryFilter(bool colorful) { diff --git a/lib/widgets/album/thumbnail/overlay.dart b/lib/widgets/album/thumbnail/overlay.dart index 8b839d9c4..c4893f9ec 100644 --- a/lib/widgets/album/thumbnail/overlay.dart +++ b/lib/widgets/album/thumbnail/overlay.dart @@ -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, diff --git a/lib/widgets/album/thumbnail/raster.dart b/lib/widgets/album/thumbnail/raster.dart index 08de412b0..2fc004938 100644 --- a/lib/widgets/album/thumbnail/raster.dart +++ b/lib/widgets/album/thumbnail/raster.dart @@ -81,7 +81,7 @@ class _ThumbnailRasterImageState extends State { @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 { final image = _sizedThumbnailProvider == null ? fastImage : Image( - key: const ValueKey('HQ'), + key: ValueKey('HQ'), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded) return child; return AnimatedSwitcher( diff --git a/lib/widgets/album/thumbnail_collection.dart b/lib/widgets/album/thumbnail_collection.dart index a6883a415..5b79f008b 100644 --- a/lib/widgets/album/thumbnail_collection.dart +++ b/lib/widgets/album/thumbnail_collection.dart @@ -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 { 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 { child: _buildEmptyCollectionPlaceholder(collection), hasScrollBody: false, ) - : const CollectionListSliver(), + : CollectionListSliver(), SliverToBoxAdapter( child: Selector( selector: (context, mq) => mq.viewInsets.bottom, @@ -211,22 +211,22 @@ class _CollectionScrollViewState extends State { 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.anyVideo)) { - return const EmptyContent( + return EmptyContent( icon: AIcons.video, text: 'No videos', ); } - return const EmptyContent( + return EmptyContent( icon: AIcons.image, text: 'No images', ); diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 0f5da71f0..e9eac9d66 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -46,7 +46,7 @@ class _AppDrawerState extends State { ), ), 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 { 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 { 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.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 { 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 { 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 { 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 { 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 { 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(), builder: (context, snapshot) { @@ -214,8 +214,8 @@ class _AppDrawerState extends State { 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(), builder: (context, snapshot) { @@ -236,8 +236,8 @@ class _AppDrawerState extends State { 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(), builder: (context, snapshot) { @@ -263,7 +263,7 @@ class _AppDrawerState extends State { 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 { 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 { title: 'Tags', filterEntries: source.getTagEntries(), filterBuilder: (s) => TagFilter(s), - emptyBuilder: () => const EmptyContent( + emptyBuilder: () => EmptyContent( icon: AIcons.tag, text: 'No tags', ), diff --git a/lib/widgets/common/action_delegates/create_album_dialog.dart b/lib/widgets/common/action_delegates/create_album_dialog.dart index 1a8881c86..51baf33a5 100644 --- a/lib/widgets/common/action_delegates/create_album_dialog.dart +++ b/lib/widgets/common/action_delegates/create_album_dialog.dart @@ -34,7 +34,7 @@ class _CreateAlbumDialogState extends State { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('New Album'), + title: Text('New Album'), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -42,8 +42,8 @@ class _CreateAlbumDialogState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ - const Text('Storage:'), - const SizedBox(width: 8), + Text('Storage:'), + SizedBox(width: 8), Expanded( child: DropdownButton( isExpanded: true, @@ -68,7 +68,7 @@ class _CreateAlbumDialogState extends State { ), ], ), - const SizedBox(height: 16), + SizedBox(height: 16), ], ValueListenableBuilder( valueListenable: _existsNotifier, @@ -84,7 +84,7 @@ class _CreateAlbumDialogState extends State { }), ], ), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: EdgeInsets.fromLTRB(24, 20, 24, 0), actions: [ FlatButton( onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index 29eb3a72e..b54a6ba50 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -120,7 +120,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { context: context, builder: (context) { return AlertDialog( - content: const Text('Are you sure?'), + content: Text('Are you sure?'), actions: [ FlatButton( onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/common/action_delegates/feedback.dart b/lib/widgets/common/action_delegates/feedback.dart index f9e8b0cae..81c36bad0 100644 --- a/lib/widgets/common/action_delegates/feedback.dart +++ b/lib/widgets/common/action_delegates/feedback.dart @@ -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, diff --git a/lib/widgets/common/action_delegates/permission_aware.dart b/lib/widgets/common/action_delegates/permission_aware.dart index 62d4d0ca0..59702f516 100644 --- a/lib/widgets/common/action_delegates/permission_aware.dart +++ b/lib/widgets/common/action_delegates/permission_aware.dart @@ -23,7 +23,7 @@ mixin PermissionAwareMixin { context: context, builder: (context) { return AlertDialog( - title: const Text('Storage Volume Access'), + 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( diff --git a/lib/widgets/common/action_delegates/selection_action_delegate.dart b/lib/widgets/common/action_delegates/selection_action_delegate.dart index e263f58b0..29bbe53da 100644 --- a/lib/widgets/common/action_delegates/selection_action_delegate.dart +++ b/lib/widgets/common/action_delegates/selection_action_delegate.dart @@ -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', ), @@ -258,7 +258,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin { child: StreamBuilder( 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( diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 66fc29dea..497bf3d69 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -32,7 +32,7 @@ class SourceStateAwareAppBarTitle extends StatelessWidget { ), ), child: sourceState == SourceState.ready - ? const SizedBox.shrink() + ? SizedBox.shrink() : SourceStateSubtitle( source: source, ), @@ -68,7 +68,7 @@ class SourceStateSubtitle extends StatelessWidget { } final subtitleStyle = Theme.of(context).textTheme.caption; return subtitle == null - ? const SizedBox.shrink() + ? SizedBox.shrink() : Row( mainAxisSize: MainAxisSize.min, children: [ @@ -76,10 +76,10 @@ class SourceStateSubtitle extends StatelessWidget { StreamBuilder( stream: source.progressStream, builder: (context, snapshot) { - if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); + if (snapshot.hasError || !snapshot.hasData) return SizedBox.shrink(); final progress = snapshot.data; return Padding( - padding: const EdgeInsetsDirectional.only(start: 8), + padding: EdgeInsetsDirectional.only(start: 8), child: Text( '${progress.done}/${progress.total}', style: subtitleStyle.copyWith(color: Colors.white30), diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index 8cce670b0..6c7f402c1 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -66,7 +66,7 @@ class _AvesFilterChipState extends State { 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 { 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 { ), ), if (trailing != null) ...[ - const SizedBox(width: AvesFilterChip.padding), + SizedBox(width: AvesFilterChip.padding), trailing, ], ], @@ -102,7 +102,7 @@ class _AvesFilterChipState extends State { } 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 { 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 { final borderRadius = AvesFilterChip.borderRadius; Widget chip = Container( - constraints: const BoxConstraints( + constraints: BoxConstraints( minWidth: AvesFilterChip.minChipWidth, maxWidth: AvesFilterChip.maxChipWidth, minHeight: AvesFilterChip.minChipHeight, @@ -171,7 +171,7 @@ class _AvesFilterChipState extends State { ), position: DecorationPosition.foreground, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: EdgeInsets.symmetric(vertical: 8), child: content, ), ); diff --git a/lib/widgets/common/data_providers/media_store_collection_provider.dart b/lib/widgets/common/data_providers/media_store_collection_provider.dart index 2585181c4..62bf42636 100644 --- a/lib/widgets/common/data_providers/media_store_collection_provider.dart +++ b/lib/widgets/common/data_providers/media_store_collection_provider.dart @@ -55,7 +55,7 @@ class MediaStoreSource extends CollectionSource { allNewEntries.addAll(pendingNewEntries); addAll(pendingNewEntries); pendingNewEntries.clear(); - }; + } ImageFileService.getImageEntries(knownEntryMap).listen( (entry) { pendingNewEntries.add(entry); diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 64ddf399f..ef354b4bf 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -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), ], ), diff --git a/lib/widgets/common/link_chip.dart b/lib/widgets/common/link_chip.dart index bddceacdf..8b7e801d8 100644 --- a/lib/widgets/common/link_chip.dart +++ b/lib/widgets/common/link_chip.dart @@ -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, diff --git a/lib/widgets/common/menu_row.dart b/lib/widgets/common/menu_row.dart index ce73e31d8..29cf302bb 100644 --- a/lib/widgets/common/menu_row.dart +++ b/lib/widgets/common/menu_row.dart @@ -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)), ], diff --git a/lib/widgets/common/scroll_thumb.dart b/lib/widgets/common/scroll_thumb.dart index cc2b9bcec..c2434f326 100644 --- a/lib/widgets/common/scroll_thumb.dart +++ b/lib/widgets/common/scroll_thumb.dart @@ -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), ), ), diff --git a/lib/widgets/debug_page.dart b/lib/widgets/debug_page.dart index 736151d48..bc046a965 100644 --- a/lib/widgets/debug_page.dart +++ b/lib/widgets/debug_page.dart @@ -51,8 +51,8 @@ class DebugPageState extends State { 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)), @@ -81,9 +81,9 @@ class DebugPageState extends State { 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), @@ -92,24 +92,24 @@ class DebugPageState extends State { 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'), ), ], ), @@ -118,43 +118,43 @@ class DebugPageState extends State { 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), - const RaisedButton( + SizedBox(width: 8), + RaisedButton( onPressed: ImageFileService.clearSizedThumbnailDiskCache, child: Text('Clear'), ), ], ), - const Divider(), + Divider(), FutureBuilder( future: _dbFileSizeLoader, 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'), ), ], ); @@ -164,16 +164,16 @@ class DebugPageState extends State { future: _dbEntryLoader, 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'), ), ], ); @@ -183,16 +183,16 @@ class DebugPageState extends State { future: _dbDateLoader, 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'), ), ], ); @@ -202,16 +202,16 @@ class DebugPageState extends State { future: _dbMetadataLoader, 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'), ), ], ); @@ -221,16 +221,16 @@ class DebugPageState extends State { future: _dbAddressLoader, 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'), ), ], ); @@ -240,16 +240,16 @@ class DebugPageState extends State { future: _dbFavouritesLoader, 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'), ), ], ); @@ -261,17 +261,17 @@ class DebugPageState extends State { 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'), ), ], ), @@ -287,7 +287,7 @@ class DebugPageState extends State { Widget _buildStorageTabView() { return ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(16), children: [ ...androidFileUtils.storageVolumes.expand((v) => [ Text(v.path), @@ -298,7 +298,7 @@ class DebugPageState extends State { 'isRemovable': '${v.isRemovable}', 'state': '${v.state}', }), - const Divider(), + Divider(), ]) ], ); @@ -306,13 +306,13 @@ class DebugPageState extends State { Widget _buildEnvTabView() { return ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(16), children: [ FutureBuilder( future: _envLoader, 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); }, diff --git a/lib/widgets/filter_grid_page.dart b/lib/widgets/filter_grid_page.dart index caee6507d..437f1e9c1 100644 --- a/lib/widgets/filter_grid_page.dart +++ b/lib/widgets/filter_grid_page.dart @@ -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, ], ) diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 0ebf2129e..443444651 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -37,8 +37,8 @@ class _FullscreenDebugPageState extends State { 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'), @@ -60,13 +60,13 @@ class _FullscreenDebugPageState extends State { Widget _buildDbTabView() { final catalog = widget.entry.catalogMetadata; return ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(16), children: [ FutureBuilder( future: _dbDateLoader, 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, @@ -80,12 +80,12 @@ class _FullscreenDebugPageState extends State { ); }, ), - const SizedBox(height: 16), + SizedBox(height: 16), FutureBuilder( future: _dbMetadataLoader, 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, @@ -106,12 +106,12 @@ class _FullscreenDebugPageState extends State { ); }, ), - const SizedBox(height: 16), + SizedBox(height: 16), FutureBuilder( future: _dbAddressLoader, 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, @@ -129,7 +129,7 @@ class _FullscreenDebugPageState extends State { ); }, ), - const Divider(), + Divider(), Text('Catalog metadata:${catalog == null ? ' no data' : ''}'), if (catalog != null) InfoRowGroup({ @@ -153,13 +153,13 @@ class _FullscreenDebugPageState extends State { Widget _buildContentResolverTabView() { return ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(16), children: [ FutureBuilder( future: _contentResolverMetadataLoader, 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'; diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 8f8dfc7e4..d87c43549 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -87,7 +87,7 @@ class FullscreenBodyState extends State 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 with SingleTickerProvide final child = ValueListenableBuilder( 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 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 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; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index b9ff3042c..c40291ee5 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -39,7 +39,7 @@ class MultiImagePageState extends State 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]; diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 25812a156..91f84b82a 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -41,7 +41,7 @@ class ImageView extends StatelessWidget { entry: entry, controller: videoController, ) - : const SizedBox(), + : SizedBox(), backgroundDecoration: backgroundDecoration, scaleStateChangedCallback: onScaleChanged, minScale: PhotoViewComputedScale.contained, @@ -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, diff --git a/lib/widgets/fullscreen/info/basic_section.dart b/lib/widgets/fullscreen/info/basic_section.dart index 24888eeb8..0880e9867 100644 --- a/lib/widgets/fullscreen/info/basic_section.dart +++ b/lib/widgets/fullscreen/info/basic_section.dart @@ -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, diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index e846ff362..9142f62ca 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -40,11 +40,11 @@ class InfoPageState extends State { 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, ); @@ -77,7 +77,7 @@ class InfoPageState extends State { 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 { 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; - Widget 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'), ), ], ); diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index c7c4cbbc3..26b57c96f 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -90,7 +90,7 @@ class _LocationSectionState extends State { 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 { ), 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 { ); } else { _loadedUri = null; - return const SizedBox.shrink(); + return SizedBox.shrink(); } } @@ -175,7 +175,7 @@ class ImageMapState extends State 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 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...', ), diff --git a/lib/widgets/fullscreen/info/metadata_section.dart b/lib/widgets/fullscreen/info/metadata_section.dart index 37b9f00bd..f2544e1a4 100644 --- a/lib/widgets/fullscreen/info/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata_section.dart @@ -65,7 +65,7 @@ class _MetadataSectionSliverState extends State 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 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 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, diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index a3e21c8f6..772c36b26 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -90,7 +90,7 @@ class _FullscreenBottomOverlayState extends State { _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)), diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index 662fd9606..0d01f4acd 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -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>( 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( itemBuilder: (context) => [ ...inAppActions.map(_buildPopupMenuItem), - const PopupMenuDivider(), + PopupMenuDivider(), ...externalAppActions.map(_buildPopupMenuItem), if (kDebugMode) ...[ - const PopupMenuDivider(), + PopupMenuDivider(), _buildPopupMenuItem(EntryAction.debug), ] ], @@ -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 _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, ), ], diff --git a/lib/widgets/fullscreen/overlay/video.dart b/lib/widgets/fullscreen/overlay/video.dart index a1b6d2270..7b7e93405 100644 --- a/lib/widgets/fullscreen/overlay/video.dart +++ b/lib/widgets/fullscreen/overlay/video.dart @@ -105,7 +105,7 @@ class VideoControlOverlayState extends State with SingleTic 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 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 with SingleTic Expanded( child: _buildProgressBar(), ), - const SizedBox(width: 8), + SizedBox(width: 8), OverlayButton( scale: scale, child: IconButton( @@ -178,11 +178,11 @@ class VideoControlOverlayState extends State with SingleTic 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 with SingleTic final position = videoInfo.currentPosition?.floor() ?? 0; return Text(formatDuration(Duration(seconds: position))); }), - const Spacer(), + Spacer(), Text(entry.durationText), ], ), diff --git a/lib/widgets/fullscreen/video_view.dart b/lib/widgets/fullscreen/video_view.dart index 092f4a78b..5127f2f91 100644 --- a/lib/widgets/fullscreen/video_view.dart +++ b/lib/widgets/fullscreen/video_view.dart @@ -60,7 +60,7 @@ class AvesVideoState extends State { @override Widget build(BuildContext context) { - if (controller == null) return const SizedBox(); + if (controller == null) return SizedBox(); return StreamBuilder( stream: widget.controller.ijkStatusStream, builder: (context, snapshot) { @@ -68,8 +68,8 @@ class AvesVideoState extends State { 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 diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 1444bc71b..6365e0c1e 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -95,8 +95,8 @@ class _HomePageState extends State { return FutureBuilder( future: _appSetup, builder: (context, snapshot) { - if (snapshot.hasError) return const Icon(AIcons.error); - if (snapshot.connectionState != ConnectionState.done) return const Scaffold(); + 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 { sortFactor: settings.collectionSortFactor, )); } - return const SizedBox.shrink(); + return SizedBox.shrink(); }); } } diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index f3b306413..1646c06ec 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -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, @@ -68,13 +68,13 @@ class FilterTable extends StatelessWidget { ), Text( '$count', - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: Colors.white70), textAlign: TextAlign.end, ), ], ); }).toList(), - columnWidths: const { + columnWidths: { 0: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(chipWidth)), 2: MaxColumnWidth(IntrinsicColumnWidth(), FixedColumnWidth(countWidth)), }, diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index ef5be10c1..1a448b76d 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -49,7 +49,7 @@ class StatsPage extends StatelessWidget { Widget build(BuildContext context) { Widget child; if (collection.isEmpty) { - child = const EmptyContent( + child = EmptyContent( icon: AIcons.image, text: 'No images', ); @@ -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,7 +118,7 @@ class StatsPage extends StatelessWidget { } Widget _buildMimeDonut(BuildContext context, String Function(num) label, Map byMimeTypes) { - if (byMimeTypes.isEmpty) return const SizedBox.shrink(); + if (byMimeTypes.isEmpty) return SizedBox.shrink(); final sum = byMimeTypes.values.fold(0, (prev, v) => prev + v); @@ -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, diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 9485b0d89..049475f8d 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -32,11 +32,11 @@ class _WelcomePageState extends State { body: SafeArea( child: Container( alignment: Alignment.center, - padding: const EdgeInsets.all(16.0), + padding: EdgeInsets.all(16.0), child: FutureBuilder( future: _termsLoader, builder: (context, snapshot) { - if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); final terms = snapshot.data; return Column( mainAxisSize: MainAxisSize.min, @@ -73,21 +73,21 @@ class _WelcomePageState extends State { 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 { 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 { button, ] : [ - const SizedBox(height: 16), + SizedBox(height: 16), Row( children: [ checkbox, - const Spacer(), + Spacer(), button, ], ), @@ -135,7 +135,7 @@ class _WelcomePageState extends State { borderRadius: BorderRadius.circular(16), color: Colors.white10, ), - constraints: const BoxConstraints(maxWidth: 460), + constraints: BoxConstraints(maxWidth: 460), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Markdown( From 8eba4f4e6411723d29636d1ae54d9160fb24a022 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 12:16:04 +0900 Subject: [PATCH 16/18] updated readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a6eb20a03..0763ea2da 100644 --- a/README.md +++ b/README.md @@ -21,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 @@ -33,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 From aa8a3577dcbe76a4d7627218d97430734bf382ed Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 12:22:38 +0900 Subject: [PATCH 17/18] pub upgrade --- pubspec.lock | 66 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 4df3672d0..7f6af3b0d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -28,7 +28,7 @@ packages: name: barcode url: "https://pub.dartlang.org" source: hosted - version: "1.13.0" + version: "1.14.0" boolean_selector: dependency: transitive description: @@ -103,6 +103,13 @@ packages: url: "git://github.com/deckerst/expansion_tile_card.git" source: git version: "1.0.3" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.1" firebase_crashlytics: dependency: "direct main" description: @@ -295,6 +302,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.4" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" pdf: dependency: "direct main" description: @@ -346,6 +367,13 @@ packages: url: "git://github.com/deckerst/photo_view.git" source: git version: "0.9.2" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" platform_detect: dependency: transitive description: @@ -367,13 +395,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.5.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.13" provider: dependency: "direct main" description: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.1.3" + version: "4.3.1" pub_semver: dependency: transitive description: @@ -408,7 +443,14 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "0.5.7+3" + version: "0.5.8" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.2+1" shared_preferences_macos: dependency: transitive description: @@ -497,7 +539,7 @@ packages: name: synchronized url: "https://pub.dartlang.org" source: hosted - version: "2.2.0+1" + version: "2.2.0+2" term_glyph: dependency: transitive description: @@ -532,7 +574,14 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "5.4.11" + version: "5.5.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+1" url_launcher_macos: dependency: transitive description: @@ -575,6 +624,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.8" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" xml: dependency: transitive description: From 19314ccfd471e5dcfdb252a50ad10c92bcf00c94 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 26 Jul 2020 12:23:42 +0900 Subject: [PATCH 18/18] version bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 5965f3931..721609164 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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)