diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3b846a16..f8c86617e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,9 +50,15 @@ jobs: echo "${{ secrets.KEY_JKS }}" > release.keystore.asc gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE rm release.keystore.asc - flutter build appbundle --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json - flutter build apk --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json - flutter build apk --flavor byAbi --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json + mkdir outputs + (cd scripts/; ./apply_flavor_play.sh) + flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.5.3.sksl.json + cp build/app/outputs/bundle/playRelease/*.aab outputs + flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.5.3.sksl.json + cp build/app/outputs/apk/play/release/*.apk outputs + (cd scripts/; ./apply_flavor_izzy.sh) + flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json + cp build/app/outputs/apk/izzy/release/*.apk outputs rm $AVES_STORE_FILE env: AVES_STORE_FILE: ${{ github.workspace }}/key.jks @@ -64,14 +70,14 @@ jobs: - name: Create a release with the APK and App Bundle. uses: ncipollo/release-action@v1 with: - artifacts: "build/app/outputs/bundle/universalRelease/*.aab,build/app/outputs/apk/universal/release/*.apk,build/app/outputs/apk/byAbi/release/*.apk" + artifacts: "outputs/*" token: ${{ secrets.GITHUB_TOKEN }} - name: Upload app bundle uses: actions/upload-artifact@v2 with: name: appbundle - path: build/app/outputs/bundle/universalRelease/app-universal-release.aab + path: outputs/app-play-release.aab release: name: Create beta release on Play Store. @@ -90,7 +96,7 @@ jobs: with: serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} packageName: deckers.thibault.aves - releaseFiles: app-universal-release.aab + releaseFiles: app-play-release.aab track: beta status: completed whatsNewDirectory: whatsnew diff --git a/CHANGELOG.md b/CHANGELOG.md index 08cf0d672..9dbfbb6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.5] - 2021-11-08 + +### Added + +- Russian translation (thanks D3ZOXY) +- Info: set date from title +- Collection: bulk editing (rotation, date setting, metadata removal) +- Collection: custom quick actions for item browsing +- Collection: live title filter +- About: link to privacy policy +- Video: quick action to play video in other app +- Video: resume playback + +### Changed + +- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no Crashlytics) +- use 12/24 hour format settings from device to display times +- Privacy: consent request on first launch for installed app inventory access +- use File API to rename and delete items, when possible (primary storage, Android <11) +- Video: changed video thumbnail strategy + +## [v1.5.4] - 2021-10-21 + ### Added - Collection: use a foreground service when scanning many items diff --git a/README.md b/README.md index 0603e79f4..e402b934d 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,22 @@ At this stage this project does *not* accept PRs, except for translations. If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled. ### Donations + Some users have expressed the wish to financially support the project. I haven't set up any sponsorship system, but you can send contributions [here](https://paypal.me/ThibaultDeckers). Thanks! ❤️ ## Project Setup +Before running or building the app, update the dependencies for the desired flavor: +``` +# (cd scripts/; ./apply_flavor_play.sh) +``` + To build the project, create a file named `/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys. -You can run the app with `flutter run --flavor universal`. +To run the app: +``` +# flutter run -t lib/main_play.dart --flavor play +``` [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/Quality%20check diff --git a/android/app/build.gradle b/android/app/build.gradle index 8cf3179fb..e46719768 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,8 +2,6 @@ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' - id 'com.google.gms.google-services' - id 'com.google.firebase.crashlytics' } def appId = "deckers.thibault.aves" @@ -77,18 +75,25 @@ android { } } - // the "splitting" dimension and its flavors are only for building purposes: - // NDK ABI filters are not compatible with split APK generation - // but we want to generate both a universal APK without x86 libs, and split APKs - flavorDimensions "splitting" + flavorDimensions "store" productFlavors { - universal { - dimension "splitting" + play { + // Google Play + dimension "store" + ext.useCrashlytics = true + // generate a universal APK without x86 native libs + ext.useNdkAbiFilters = true } - byAbi { - dimension "splitting" + izzy { + // IzzyOnDroid + // check offending libraries with `scanapk` + // cf https://android.izzysoft.de/articles/named/app-modules-2 + dimension "store" + ext.useCrashlytics = false + // generate APK by ABI, but NDK ABI filters are incompatible with split APK generation + ext.useNdkAbiFilters = false } } @@ -108,14 +113,16 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } - def runTasks = gradle.startParameter.taskNames.toString().toLowerCase() - if (runTasks.contains("universal")) { - release { - // specify architectures, to specifically exclude native libs for x86, - // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" - // cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500 - ndk { - abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' + android.productFlavors.each { flavor -> + def tasks = gradle.startParameter.taskNames.toString().toLowerCase() + if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) { + release { + // specify architectures, to specifically exclude native libs for x86, + // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" + // cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500 + ndk { + abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' + } } } } @@ -132,7 +139,7 @@ repositories { } dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1' implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.exifinterface:exifinterface:1.3.3' implementation 'androidx.multidex:multidex:2.0.1' @@ -150,3 +157,12 @@ dependencies { compileOnly rootProject.findProject(':streams_channel') } + +android.productFlavors.each { flavor -> + def tasks = gradle.startParameter.taskRequests.toString().toLowerCase() + if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) { + println("Building flavor with Crashlytics [${flavor.name}] - applying plugin") + apply plugin: 'com.google.gms.google-services' + apply plugin: 'com.google.firebase.crashlytics' + } +} diff --git a/android/app/libs/fijkplayer-full-release.aar b/android/app/libs/fijkplayer-full-release.aar index 4a5f64390..53c1adfdf 100644 Binary files a/android/app/libs/fijkplayer-full-release.aar and b/android/app/libs/fijkplayer-full-release.aar differ diff --git a/android/app/src/debug/res/values-ko/strings.xml b/android/app/src/debug/res/values-ko/strings.xml deleted file mode 100644 index 07e7e9a8f..000000000 --- a/android/app/src/debug/res/values-ko/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 아베스 [Debug] - \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt index c35efa712..d4cfcb806 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt @@ -17,7 +17,6 @@ import deckers.thibault.aves.channel.calls.MediaStoreHandler import deckers.thibault.aves.channel.calls.MetadataFetchHandler import deckers.thibault.aves.channel.streams.ImageByteStreamHandler import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler -import deckers.thibault.aves.utils.ContextUtils.runOnUiThread import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.LogUtils import io.flutter.embedding.engine.FlutterEngine @@ -155,12 +154,11 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { private inner class ServiceHandler(looper: Looper) : Handler(looper) { override fun handleMessage(msg: Message) { - val context = this@AnalysisService val data = msg.data when (data.getString(KEY_COMMAND)) { COMMAND_START -> { runBlocking { - context.runOnUiThread { + FlutterUtils.runOnUiThread { val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() } backgroundChannel?.invokeMethod( "start", hashMapOf( @@ -174,7 +172,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { COMMAND_STOP -> { // unconditionally stop the service runBlocking { - context.runOnUiThread { + FlutterUtils.runOnUiThread { backgroundChannel?.invokeMethod("stop", null) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 9dd4032e5..64cba6a9c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -311,7 +311,10 @@ class MainActivity : FlutterActivity() { var errorStreamHandler: ErrorStreamHandler? = null - fun notifyError(error: String) = errorStreamHandler?.notifyError(error) + suspend fun notifyError(error: String) { + Log.e(LOG_TAG, "notifyError error=$error") + errorStreamHandler?.notifyError(error) + } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt index e045954f9..ca9ba2fa7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -8,10 +8,10 @@ import android.database.Cursor import android.database.MatrixCursor import android.net.Uri import android.os.Build +import android.text.format.DateFormat import android.util.Log import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.ContextUtils.resourceUri -import deckers.thibault.aves.utils.ContextUtils.runOnUiThread import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.LogUtils import io.flutter.embedding.engine.FlutterEngine @@ -79,10 +79,11 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid return suspendCoroutine { cont -> GlobalScope.launch { - context.runOnUiThread { + FlutterUtils.runOnUiThread { backgroundChannel.invokeMethod("getSuggestions", hashMapOf( "query" to query, "locale" to Locale.getDefault().toString(), + "use24hour" to DateFormat.is24HourFormat(context), ), object : MethodChannel.Result { override fun success(result: Any?) { @Suppress("unchecked_cast") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt index b5eab9563..1896fd0ef 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves.channel.streams +import deckers.thibault.aves.utils.FlutterUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink @@ -15,8 +16,10 @@ class ErrorStreamHandler : EventChannel.StreamHandler { override fun onCancel(arguments: Any?) {} - fun notifyError(error: String) { - eventSink?.success(error) + suspend fun notifyError(error: String) { + FlutterUtils.runOnUiThread { + eventSink?.success(error) + } } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 4a63445bf..6586c5df8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -49,7 +49,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? } } - private fun requestDirectoryAccess() { + private suspend fun requestDirectoryAccess() { val path = args["path"] as String? if (path == null) { error("requestDirectoryAccess-args", "failed because of missing arguments", null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt index 80a158fab..63fe82405 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt @@ -19,7 +19,6 @@ import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.signature.ObjectKey import deckers.thibault.aves.metadata.MultiTrackMedia - @GlideModule class MultiTrackImageGlideModule : LibraryGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt index d0f2138ff..6c548ef44 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt @@ -3,7 +3,6 @@ package deckers.thibault.aves.decoder import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Build import com.bumptech.glide.Glide import com.bumptech.glide.Priority import com.bumptech.glide.Registry @@ -58,16 +57,13 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe try { var bytes = retriever.embeddedPicture if (bytes == null) { - // try to match the thumbnails returned by the content resolver / Media Store - // the following strategies are from empirical evidence from a few test devices: - // - API 29: sync frame closest to the middle - // - API 26/27: default representative frame at any time position + // there is no consistent strategy across devices to match + // the thumbnails returned by the content resolver / Media Store + // so we derive one in an arbitrary way var timeMillis: Long? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() - if (durationMillis != null) { - timeMillis = durationMillis / 2 - } + val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + if (durationMillis != null) { + timeMillis = if (durationMillis < 15000) 0 else 15000 } val frame = if (timeMillis != null) { retriever.getFrameAtTime(timeMillis * 1000) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index d129b187c..e70aa3437 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Binder import android.os.Build import android.util.Log -import androidx.annotation.RequiresApi import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat @@ -734,7 +733,7 @@ abstract class ImageProvider { targetUri: Uri, targetPath: String ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaUriPermissionGranted(context, targetUri, mimeType)) { + if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri") DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) } else { @@ -758,14 +757,17 @@ abstract class ImageProvider { // used when skipping a move/creation op because the target file already exists val skippedFieldMap: HashMap = hashMapOf("skipped" to true) - @RequiresApi(Build.VERSION_CODES.Q) fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean { - val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) - val pid = Binder.getCallingPid() - val uid = Binder.getCallingUid() - val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION - return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED + val pid = Binder.getCallingPid() + val uid = Binder.getCallingUid() + val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED + } else { + false + } } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 14bdb0260..2d3e62f9e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -12,7 +12,6 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.util.Log -import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST @@ -226,47 +225,42 @@ class MediaStoreImageProvider : ImageProvider() { return found } - private fun hasEntry(context: Context, contentUri: Uri): Boolean { - var found = false - val projection = arrayOf(MediaStore.MediaColumns._ID) - try { - val cursor = context.contentResolver.query(contentUri, projection, null, null, null) - if (cursor != null) { - while (cursor.moveToNext()) { - found = true - } - cursor.close() - } - } catch (e: Exception) { - Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e) - } - return found - } - private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { - if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && isMediaUriPermissionGranted(activity, uri, mimeType)) - ) { - // 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 when the app has the permission, so we manually delete the document file - path ?: throw Exception("failed to delete file because path is null") - if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) { + path ?: throw Exception("failed to delete file because path is null") + + val file = File(path) + if (file.exists()) { + if (StorageUtils.canEditByFile(activity, path)) { + Log.d(LOG_TAG, "delete file at uri=$uri path=$path") + if (file.delete()) { + scanObsoletePath(activity, path, mimeType) + return + } + } else if (!isMediaUriPermissionGranted(activity, uri, mimeType) + && StorageUtils.requireAccessPermission(activity, 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 when the app has the permission, so we manually delete the document file Log.d(LOG_TAG, "delete document at uri=$uri path=$path") val df = StorageUtils.getDocumentFile(activity, path, uri) @Suppress("BlockingMethodInNonBlockingContext") - if (df != null && df.delete()) return - throw Exception("failed to delete file with df=$df") + if (df != null && df.delete()) { + scanObsoletePath(activity, path, mimeType) + return + } + throw Exception("failed to delete document with df=$df") } } try { - Log.d(LOG_TAG, "delete content at uri=$uri") + Log.d(LOG_TAG, "delete content at uri=$uri path=$path") if (activity.contentResolver.delete(uri, null, null) > 0) return + throw Exception("failed to delete row from content provider") } catch (securityException: SecurityException) { // even if the app has access permission granted on the containing directory, // the delete request may yield a `RecoverableSecurityException` on Android 10+ @@ -291,7 +285,6 @@ class MediaStoreImageProvider : ImageProvider() { throw securityException } } - throw Exception("failed to delete row from content provider") } override suspend fun moveMultiple( @@ -330,6 +323,7 @@ class MediaStoreImageProvider : ImageProvider() { // with a path, and retrieve its content URI, but: // - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`) // - the volume name should be lower case, not exactly as the `StorageVolume` UUID + // cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()` // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?) // - 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 @@ -513,31 +507,30 @@ class MediaStoreImageProvider : ImageProvider() { ): FieldMap { val oldFile = File(oldPath) val newFile = File(oldFile.parent, newFileName) - if (oldFile == newFile) { - // nothing to do - return skippedFieldMap - } - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) - ) { - renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) - } else { - renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) + return when { + oldFile == newFile -> skippedFieldMap + StorageUtils.canEditByFile(activity, oldPath) -> renameSingleByFile(activity, mimeType, oldPath, newFile) + isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) -> renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) + else -> renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) } } - @RequiresApi(Build.VERSION_CODES.Q) private suspend fun renameSingleByMediaStore( activity: Activity, mimeType: String, mediaUri: Uri, newFile: File ): FieldMap { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw Exception("unsupported Android version") + } + val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType) // `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME` - val tempValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) } + val tempValues = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 1) + } if (activity.contentResolver.update(uri, tempValues, null, null) == 0) { throw Exception("failed to update fields for uri=$uri") } @@ -567,39 +560,25 @@ class MediaStoreImageProvider : ImageProvider() { @Suppress("BlockingMethodInNonBlockingContext") val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false if (!renamed) { - throw Exception("failed to rename entry at path=$oldPath") + throw Exception("failed to rename document at path=$oldPath") } - - // Renaming may be successful and the file at the old path no longer exists - // but, in some situations, scanning the old path does not clear the Media Store entry. - // For higher chance of accurate obsolete item check, keep this order: - // 1) scan obsolete item, - // 2) scan current item, - // 3) check obsolete item in Media Store - scanObsoletePath(activity, oldPath, mimeType) - val newFields = scanNewPath(activity, newFile.path, mimeType) + return scanNewPath(activity, newFile.path, mimeType) + } - if (hasEntry(activity, oldMediaUri)) { - Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFile=$newFile did not clear the MediaStore entry for obsolete path=$oldPath") - - // On Android Q (emulator/Mi9TPro), the concept of owner package disrupts renaming and the Media Store keeps an obsolete entry, - // but we use legacy external storage, so at least we do not have to deal with a `RecoverableSecurityException` - // when deleting this obsolete entry which is not backed by a file anymore. - // On Android R (S10e), everything seems fine! - // On Android S (emulator), renaming always leaves an obsolete entry whatever the owner package, - // and we get a `RecoverableSecurityException` if we attempt to delete this obsolete entry, - // but the entry seems to be cleaned later automatically by the Media Store anyway. - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { - try { - delete(activity, oldMediaUri, oldPath, mimeType) - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e) - } - } + private suspend fun renameSingleByFile( + activity: Activity, + mimeType: String, + oldPath: String, + newFile: File + ): FieldMap { + Log.d(LOG_TAG, "rename file at path=$oldPath") + val renamed = File(oldPath).renameTo(newFile) + if (!renamed) { + throw Exception("failed to rename file at path=$oldPath") } - - return newFields + scanObsoletePath(activity, oldPath, mimeType) + return scanNewPath(activity, newFile.path, mimeType) } override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap, callback: ImageOpCallback) { @@ -658,30 +637,29 @@ class MediaStoreImageProvider : ImageProvider() { return null } - if (newUri == null) { - cont.resumeWithException(Exception("failed to get URI of item at path=$path")) - return@scanFile - } - - var contentUri: Uri? = null - // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") - // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") - val contentId = newUri.tryParseId() - if (contentId != null) { - if (isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) - } else if (isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) + if (newUri != null) { + var contentUri: Uri? = null + // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") + // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") + val contentId = newUri.tryParseId() + if (contentId != null) { + if (isImage(mimeType)) { + contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) + } else if (isVideo(mimeType)) { + contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) + } } - } - // prefer image/video content URI, fallback to original URI (possibly a file content URI) - val newFields = scanUri(contentUri) ?: scanUri(newUri) + // prefer image/video content URI, fallback to original URI (possibly a file content URI) + val newFields = scanUri(contentUri) ?: scanUri(newUri) - if (newFields != null) { - cont.resume(newFields) + if (newFields != null) { + cont.resume(newFields) + } else { + cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) + } } else { - cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) + cont.resumeWithException(Exception("failed to get URI of item at path=$path")) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt index d31dd5c26..7b4d57941 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -5,10 +5,6 @@ import android.app.Service import android.content.ContentResolver import android.content.Context import android.net.Uri -import android.os.Handler -import android.os.Looper -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine object ContextUtils { fun Context.resourceUri(resourceId: Int): Uri = with(resources) { @@ -20,19 +16,6 @@ object ContextUtils { .build() } - suspend fun Context.runOnUiThread(r: Runnable) { - if (Looper.myLooper() != mainLooper) { - suspendCoroutine { cont -> - Handler(mainLooper).post { - r.run() - cont.resume(true) - } - } - } else { - r.run() - } - } - fun Context.isMyServiceRunning(serviceClass: Class): Boolean { val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? am ?: return false diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt index 98a1dbed6..973743105 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt @@ -1,13 +1,16 @@ package deckers.thibault.aves.utils import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log -import deckers.thibault.aves.utils.ContextUtils.runOnUiThread import io.flutter.FlutterInjector import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.view.FlutterCallbackInformation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine object FlutterUtils { private val LOG_TAG = LogUtils.createTag() @@ -20,7 +23,7 @@ object FlutterUtils { } lateinit var flutterLoader: FlutterLoader - context.runOnUiThread { + FlutterUtils.runOnUiThread { // initialization must happen on the main thread flutterLoader = FlutterInjector.instance().flutterLoader().apply { startInitialization(context) @@ -39,11 +42,25 @@ object FlutterUtils { flutterLoader.findAppBundlePath(), callbackInfo ) - context.runOnUiThread { + runOnUiThread { val engine = FlutterEngine(context).apply { dartExecutor.executeDartCallback(args) } engineSetter(engine) } } + + suspend fun runOnUiThread(r: Runnable) { + val mainLooper = Looper.getMainLooper() + if (Looper.myLooper() != mainLooper) { + suspendCoroutine { cont -> + Handler(mainLooper).post { + r.run() + cont.resume(true) + } + } + } else { + r.run() + } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index eb2e8bcd4..6416286b2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -137,6 +137,8 @@ object MimeTypes { // extensions + // among other refs: + // - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types fun extensionFor(mimeType: String): String? = when (mimeType) { ARW -> ".arw" AVI, AVI_VND -> ".avi" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 1655f9430..32c40d1da 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -31,13 +31,18 @@ object PermissionManager { ) @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { + suspend fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { Log.i(LOG_TAG, "request user to select and grant access permission to path=$path") var intent: Intent? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager - intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent() + val storageVolume = sm?.getStorageVolume(File(path)) + if (storageVolume != null) { + intent = storageVolume.createOpenDocumentTreeIntent() + } else { + MainActivity.notifyError("failed to get storage volume for path=$path on volumes=${sm?.storageVolumes?.joinToString(", ")}") + } } // fallback to basic open document tree intent @@ -49,7 +54,7 @@ object PermissionManager { MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) } else { - Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") + MainActivity.notifyError("failed to resolve activity for intent=$intent") onDenied() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 4691a6bee..79118d575 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -394,6 +394,8 @@ object StorageUtils { * Misc */ + fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path) + fun requireAccessPermission(context: Context, anyPath: String): Boolean { // on Android R, we should always require access permission, even on primary volume if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index 2f2fe17ec..29988cddf 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -1,4 +1,4 @@ - + 아베스 검색 diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 000000000..3ccabcd2a --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,10 @@ + + + Aves + Поиск + Видео + Сканировать медия + Сканировать изображения и видео + Сканирование медиа + Стоп + \ No newline at end of file diff --git a/android/app/src/profile/res/values-ko/strings.xml b/android/app/src/profile/res/values-ko/strings.xml deleted file mode 100644 index 37f84623f..000000000 --- a/android/app/src/profile/res/values-ko/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - 아베스 [Profile] - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index f4a02033b..7d6fe6924 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,8 +6,9 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.2' + classpath 'com.android.tools.build:gradle:7.0.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // GMS & Firebase Crashlytics are not actually used by all flavors classpath 'com.google.gms:google-services:4.3.10' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' } diff --git a/assets/terms.md b/assets/terms.md index 101150057..50e0dc078 100644 --- a/assets/terms.md +++ b/assets/terms.md @@ -1,17 +1,25 @@ -# Terms of Service -Aves is an open-source gallery and metadata explorer app allowing you to access and manage your local photos. +## Terms of Service + +“Aves Gallery” is an open-source gallery and metadata explorer app allowing you to access and manage your local photos and videos. You must use the app for legal, authorized and acceptable purposes. -# Disclaimer -This app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk. +## Disclaimer -# Privacy policy -Aves does not collect any personal data in its standard use. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up. +The app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk. -__We collect anonymous data to improve the app.__ We use Google Firebase for Crash Reporting, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data. +## Privacy Policy -## Links -[Sources](https://github.com/deckerst/aves) +The app does not collect any personal data. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up. -[License](https://github.com/deckerst/aves/blob/main/LICENSE) +__Optionally, with your consent, the app accesses the inventory of installed apps__ to improve album display. + +__Optionally, with your consent, the app collects anonymous error and diagnostic data__ to improve the app quality. We use Firebase Crashlytics, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data. + +## Contact + +Developer: Thibault Deckers + +Email: [gallery.aves@gmail.com](mailto:gallery.aves@gmail.com) + +Website: [https://github.com/deckerst/aves](https://github.com/deckerst/aves) diff --git a/l10n.yaml b/l10n.yaml index 3619cab0e..9b7cfce0e 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -7,4 +7,4 @@ preferred-supported-locales: - en -# untranslated-messages-file: untranslated.json +untranslated-messages-file: untranslated.json diff --git a/lib/app_flavor.dart b/lib/app_flavor.dart new file mode 100644 index 000000000..4dfcd54d6 --- /dev/null +++ b/lib/app_flavor.dart @@ -0,0 +1,5 @@ +enum AppFlavor { play, izzy } + +extension ExtraAppFlavor on AppFlavor { + bool get canEnableErrorReporting => this == AppFlavor.play; +} diff --git a/lib/app_mode.dart b/lib/app_mode.dart index 5238cf612..b93ec33e6 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -3,6 +3,8 @@ enum AppMode { main, pickExternal, pickInternal, view } extension ExtraAppMode on AppMode { bool get canSearch => this == AppMode.main || this == AppMode.pickExternal; + bool get canSelect => this == AppMode.main; + bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal; bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a89aaabb3..fd0091b18 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,8 +3,7 @@ "@appName": {}, "welcomeMessage": "Welcome to Aves", "@welcomeMessage": {}, - "welcomeCrashReportToggle": "Allow anonymous error reporting (optional)", - "@welcomeCrashReportToggle": {}, + "welcomeOptional": "Optional", "welcomeTermsToggle": "I agree to the terms and conditions", "@welcomeTermsToggle": {}, "itemCount": "{count, plural, =1{1 item} other{{count} items}}", @@ -176,6 +175,25 @@ "@coordinateFormatDms": {}, "coordinateFormatDecimal": "Decimal degrees", "@coordinateFormatDecimal": {}, + "coordinateDms": "{coordinate} {direction}", + "@coordinateDms": { + "placeholders": { + "coordinate": { + "type": "String" + }, + "direction": { + "type": "String" + } + } + }, + "coordinateDmsNorth": "N", + "@coordinateDmsNorth": {}, + "coordinateDmsSouth": "S", + "@coordinateDmsSouth": {}, + "coordinateDmsEast": "E", + "@coordinateDmsEast": {}, + "coordinateDmsWest": "W", + "@coordinateDmsWest": {}, "unitSystemMetric": "Metric", "@unitSystemMetric": {}, @@ -289,6 +307,18 @@ } }, + "unsupportedTypeDialogTitle": "Unsupported Types", + "@unsupportedTypeDialogTitle": {}, + "unsupportedTypeDialogMessage": "{count, plural, =1{This operation is not supported for items of the following type: {types}.} other{This operation is not supported for items of the following types: {types}.}}", + "@unsupportedTypeDialogMessage": { + "placeholders": { + "count": {}, + "types": { + "type": "String" + } + } + }, + "nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.", "@nameConflictDialogSingleSourceMessage": {}, "nameConflictDialogMultipleSourceMessage": "Some files have the same name.", @@ -311,6 +341,17 @@ } }, + "videoResumeDialogMessage": "Do you want to resume playing at {time}?", + "@videoResumeDialogMessage": { + "placeholders": { + "time": {} + } + }, + "videoStartOverButtonLabel": "START OVER", + "@videoStartOverButtonLabel": {}, + "videoResumeButtonLabel": "RESUME", + "@videoResumeButtonLabel": {}, + "setCoverDialogTitle": "Set Cover", "@setCoverDialogTitle": {}, "setCoverDialogLatest": "Latest item", @@ -360,6 +401,8 @@ "@editEntryDateDialogSet": {}, "editEntryDateDialogShift": "Shift", "@editEntryDateDialogShift": {}, + "editEntryDateDialogExtractFromTitle": "Extract from title", + "@editEntryDateDialogExtractFromTitle": {}, "editEntryDateDialogClear": "Clear", "@editEntryDateDialogClear": {}, "editEntryDateDialogFieldSelection": "Field selection", @@ -374,7 +417,7 @@ "removeEntryMetadataDialogMore": "More", "@removeEntryMetadataDialogMore": {}, - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside this motion photo. Are you sure you want to remove it?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?", "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {}, "videoSpeedDialogLabel": "Playback speed", @@ -419,6 +462,8 @@ "@aboutLinkSources": {}, "aboutLinkLicense": "License", "@aboutLinkLicense": {}, + "aboutLinkPolicy": "Privacy Policy", + "@aboutLinkPolicy": {}, "aboutUpdate": "New Version Available", "@aboutUpdate": {}, @@ -454,6 +499,8 @@ "@aboutCreditsWorldAtlas1": {}, "aboutCreditsWorldAtlas2": "under ISC License.", "@aboutCreditsWorldAtlas2": {}, + "aboutCreditsTranslators": "Translators:", + "@aboutCreditsTranslators": {}, "aboutLicenses": "Open-Source Licenses", "@aboutLicenses": {}, @@ -470,6 +517,9 @@ "aboutLicensesShowAllButtonLabel": "Show All Licenses", "@aboutLicensesShowAllButtonLabel": {}, + "policyPageTitle": "Privacy Policy", + "@policyPageTitle": {}, + "collectionPageTitle": "Collection", "@collectionPageTitle": {}, "collectionPickPageTitle": "Pick", @@ -481,6 +531,10 @@ } }, + "collectionActionShowTitleSearch": "Show title filter", + "@collectionActionShowTitleSearch": {}, + "collectionActionHideTitleSearch": "Hide title filter", + "@collectionActionHideTitleSearch": {}, "collectionActionAddShortcut": "Add shortcut", "@collectionActionAddShortcut": {}, "collectionActionCopy": "Copy to album", @@ -489,6 +543,11 @@ "@collectionActionMove": {}, "collectionActionRescan": "Rescan", "@collectionActionRescan": {}, + "collectionActionEdit": "Edit", + "@collectionActionEdit": {}, + + "collectionSearchTitlesHintText": "Search titles", + "@collectionSearchTitlesHintText": {}, "collectionSortTitle": "Sort", "@collectionSortTitle": {}, @@ -536,6 +595,12 @@ "count": {} } }, + "collectionEditFailureFeedback": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}", + "@collectionEditFailureFeedback": { + "placeholders": { + "count": {} + } + }, "collectionExportFailureFeedback": "{count, plural, =1{Failed to export 1 page} other{Failed to export {count} pages}}", "@collectionExportFailureFeedback": { "placeholders": { @@ -554,6 +619,12 @@ "count": {} } }, + "collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}", + "@collectionEditSuccessFeedback": { + "placeholders": { + "count": {} + } + }, "collectionEmptyFavourites": "No favourites", "@collectionEmptyFavourites": {}, @@ -705,10 +776,16 @@ "settingsThumbnailShowVideoDuration": "Show video duration", "@settingsThumbnailShowVideoDuration": {}, - "settingsCollectionSelectionQuickActionsTile": "Quick actions for item selection", - "@settingsCollectionSelectionQuickActionsTile": {}, - "settingsCollectionSelectionQuickActionEditorTitle": "Quick Actions", - "@settingsCollectionSelectionQuickActionEditorTitle": {}, + "settingsCollectionQuickActionsTile": "Quick actions", + "@settingsCollectionQuickActionsTile": {}, + "settingsCollectionQuickActionEditorTitle": "Quick Actions", + "@settingsCollectionQuickActionEditorTitle": {}, + "settingsCollectionQuickActionTabBrowsing": "Browsing", + "@settingsCollectionQuickActionTabBrowsing": {}, + "settingsCollectionQuickActionTabSelecting": "Selecting", + "@settingsCollectionQuickActionTabSelecting": {}, + "settingsCollectionBrowsingQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when browsing items.", + "@settingsCollectionBrowsingQuickActionEditorBanner": {}, "settingsCollectionSelectionQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when selecting items.", "@settingsCollectionSelectionQuickActionEditorBanner": {}, @@ -794,8 +871,12 @@ "settingsSectionPrivacy": "Privacy", "@settingsSectionPrivacy": {}, - "settingsEnableErrorReporting": "Allow anonymous error reporting", - "@settingsEnableErrorReporting": {}, + "settingsAllowInstalledAppAccess": "Allow access to app inventory", + "@settingsAllowInstalledAppAccess": {}, + "settingsAllowInstalledAppAccessSubtitle": "Used to improve album display", + "@settingsAllowInstalledAppAccessSubtitle": {}, + "settingsAllowErrorReporting": "Allow anonymous error reporting", + "@settingsAllowErrorReporting": {}, "settingsSaveSearchHistory": "Save search history", "@settingsSaveSearchHistory": {}, @@ -856,18 +937,6 @@ "statsPageTitle": "Stats", "@statsPageTitle": {}, - "statsImage": "{count, plural, =1{image} other{images}}", - "@statsImage": { - "placeholders": { - "count": {} - } - }, - "statsVideo": "{count, plural, =1{video} other{videos}}", - "@statsVideo": { - "placeholders": { - "count": {} - } - }, "statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}", "@statsWithGps": { "placeholders": { @@ -883,8 +952,6 @@ "viewerOpenPanoramaButtonLabel": "OPEN PANORAMA", "@viewerOpenPanoramaButtonLabel": {}, - "viewerOpenTooltip": "Open", - "@viewerOpenTooltip": {}, "viewerErrorUnknown": "Oops!", "@viewerErrorUnknown": {}, "viewerErrorDoesNotExist": "The file no longer exists.", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 472bb957c..296bdac15 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,7 +1,7 @@ { "appName": "아베스", "welcomeMessage": "아베스 사용을 환영합니다", - "welcomeCrashReportToggle": "오류 보고서를 보내는 것에 동의합니다 (선택)", + "welcomeOptional": "선택", "welcomeTermsToggle": "이용약관에 동의합니다", "itemCount": "{count, plural, other{{count}개}}", @@ -87,6 +87,11 @@ "coordinateFormatDms": "도분초", "coordinateFormatDecimal": "소수점", + "coordinateDms": "{direction} {coordinate}", + "coordinateDmsNorth": "북위", + "coordinateDmsSouth": "남위", + "coordinateDmsEast": "동경", + "coordinateDmsWest": "서경", "unitSystemMetric": "미터법", "unitSystemImperial": "야드파운드법", @@ -130,6 +135,9 @@ "notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + "unsupportedTypeDialogTitle": "미지원 형식", + "unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}", + "nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.", "nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.", @@ -141,6 +149,10 @@ "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}", + "videoResumeDialogMessage": "{time}부터 재개하시겠습니까?", + "videoStartOverButtonLabel": "처음부터", + "videoResumeButtonLabel": "재개", + "setCoverDialogTitle": "대표 이미지 변경", "setCoverDialogLatest": "최근 항목", "setCoverDialogCustom": "직접 설정", @@ -165,6 +177,7 @@ "editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogSet": "편집", "editEntryDateDialogShift": "시간 이동", + "editEntryDateDialogExtractFromTitle": "제목에서 추출", "editEntryDateDialogClear": "삭제", "editEntryDateDialogFieldSelection": "필드 선택", "editEntryDateDialogHours": "시간", @@ -173,7 +186,7 @@ "removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogMore": "더 보기", - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", "videoSpeedDialogLabel": "재생 배속", @@ -198,6 +211,7 @@ "aboutPageTitle": "앱 정보", "aboutLinkSources": "소스 코드", "aboutLinkLicense": "라이선스", + "aboutLinkPolicy": "개인정보 보호정책", "aboutUpdate": "업데이트 사용 가능", "aboutUpdateLinks1": "앱의 최신 버전을", @@ -217,6 +231,7 @@ "aboutCredits": "크레딧", "aboutCreditsWorldAtlas1": "이 앱은", "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", + "aboutCreditsTranslators": "번역가:", "aboutLicenses": "오픈 소스 라이선스", "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", @@ -226,14 +241,21 @@ "aboutLicensesDartPackages": "다트 패키지", "aboutLicensesShowAllButtonLabel": "라이선스 모두 보기", + "policyPageTitle": "개인정보 보호정책", + "collectionPageTitle": "미디어", "collectionPickPageTitle": "항목 선택", "collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}", + "collectionActionShowTitleSearch": "제목 필터 보기", + "collectionActionHideTitleSearch": "제목 필터 숨기기", "collectionActionAddShortcut": "홈 화면에 추가", "collectionActionCopy": "앨범으로 복사", "collectionActionMove": "앨범으로 이동", "collectionActionRescan": "새로 분석", + "collectionActionEdit": "편집", + + "collectionSearchTitlesHintText": "제목 검색", "collectionSortTitle": "정렬", "collectionSortDate": "날짜", @@ -253,9 +275,11 @@ "collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}", "collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}", "collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}", + "collectionEditFailureFeedback": "{count, plural, other{항목 {count}개를 편집하지 못했습니다}}", "collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}", "collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}", "collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}", + "collectionEditSuccessFeedback": "{count, plural, other{항목 {count}개를 편집했습니다}}", "collectionEmptyFavourites": "즐겨찾기가 없습니다", "collectionEmptyVideos": "동영상이 없습니다", @@ -340,8 +364,11 @@ "settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시", - "settingsCollectionSelectionQuickActionsTile": "항목 선택의 빠른 작업", - "settingsCollectionSelectionQuickActionEditorTitle": "빠른 작업", + "settingsCollectionQuickActionsTile": "빠른 작업", + "settingsCollectionQuickActionEditorTitle": "빠른 작업", + "settingsCollectionQuickActionTabBrowsing": "탐색 시", + "settingsCollectionQuickActionTabSelecting": "선택 시", + "settingsCollectionBrowsingQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 탐색할 때 표시될 버튼을 선택하세요.", "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsSectionViewer": "뷰어", @@ -387,7 +414,9 @@ "settingsSubtitleThemeTextAlignmentRight": "오른쪽", "settingsSectionPrivacy": "개인정보 보호", - "settingsEnableErrorReporting": "오류 보고서 보내기", + "settingsAllowInstalledAppAccess": "설치된 앱의 목록 접근 허용", + "settingsAllowInstalledAppAccessSubtitle": "앨범 표시 개선을 위해", + "settingsAllowErrorReporting": "오류 보고서 보내기", "settingsSaveSearchHistory": "검색기록", "settingsHiddenFiltersTile": "숨겨진 필터", @@ -421,15 +450,12 @@ "settingsUnitSystemTitle": "단위법", "statsPageTitle": "통계", - "statsImage": "{count, plural, other{사진}}", - "statsVideo": "{count, plural, other{동영상}}", "statsWithGps": "{count, plural, other{{count}개 위치가 있음}}", "statsTopCountries": "국가 랭킹", "statsTopPlaces": "장소 랭킹", "statsTopTags": "태그 랭킹", "viewerOpenPanoramaButtonLabel": "파노라마 열기", - "viewerOpenTooltip": "열기", "viewerErrorUnknown": "아이구!", "viewerErrorDoesNotExist": "파일이 존재하지 않습니다.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 000000000..1dbbe07f0 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,503 @@ +{ + "appName": "Aves", + "welcomeMessage": "Добро пожаловать в Aves", + "welcomeOptional": "Опционально", + "welcomeTermsToggle": "Я согласен с условиями и положениями", + "itemCount": "{count, plural, =1{1 объект} few{{count} объекта} other{{count} объектов}}", + + "timeSeconds": "{seconds, plural, =1{1 секунда} few{{seconds} секунды} other{{seconds} секунд}}", + "timeMinutes": "{minutes, plural, =1{1 минута} few{{minutes} минуты} other{{minutes} минут}}", + + "applyButtonLabel": "ПРИМЕНИТЬ", + "deleteButtonLabel": "УДАЛИТЬ", + "nextButtonLabel": "ДАЛЕЕ", + "showButtonLabel": "ПОКАЗАТЬ", + "hideButtonLabel": "СКРЫТЬ", + "continueButtonLabel": "ПРОДОЛЖИТЬ", + + "changeTooltip": "Изменить", + "clearTooltip": "Очистить", + "previousTooltip": "Предыдущий", + "nextTooltip": "Следующий", + "showTooltip": "Показать", + "hideTooltip": "Скрыть", + "removeTooltip": "Удалить", + + "doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.", + + "sourceStateLoading": "Загрузка", + "sourceStateCataloguing": "Каталогизация", + "sourceStateLocatingCountries": "Расположение стран", + "sourceStateLocatingPlaces": "Расположение локаций", + + "chipActionDelete": "Удалить", + "chipActionGoToAlbumPage": "Показывать в Альбомах", + "chipActionGoToCountryPage": "Показывать в Странах", + "chipActionGoToTagPage": "Показывать в тегах", + "chipActionHide": "Скрыть", + "chipActionPin": "Закрепить", + "chipActionUnpin": "Открепить", + "chipActionRename": "Переименовать", + "chipActionSetCover": "Установить обложку", + "chipActionCreateAlbum": "Создать альбом", + + "entryActionCopyToClipboard": "Скопировать в буфер обмена", + "entryActionDelete": "Удалить", + "entryActionExport": "Экспорт", + "entryActionInfo": "Информация", + "entryActionRename": "Переименовать", + "entryActionRotateCCW": "Повернуть против часовой стрелки", + "entryActionRotateCW": "Повернуть по часовой стрелки", + "entryActionFlip": "Отразить по горизонтали", + "entryActionPrint": "Печать", + "entryActionShare": "Поделиться", + "entryActionViewSource": "Посмотреть источник", + "entryActionViewMotionPhotoVideo": "Открыть «Живые фото»", + "entryActionEdit": "Изменить с помощью…", + "entryActionOpen": "Открыть с помощью…", + "entryActionSetAs": "Установить как…", + "entryActionOpenMap": "Показать на карте…", + "entryActionRotateScreen": "Повернуть экран", + "entryActionAddFavourite": "Добавить в избранное", + "entryActionRemoveFavourite": "Удалить из избранного", + + "videoActionCaptureFrame": "Сохранить кадр", + "videoActionPause": "Стоп", + "videoActionPlay": "Играть", + "videoActionReplay10": "Перемотка на 10 секунд назад", + "videoActionSkip10": "Перемотка на 10 секунд вперёд", + "videoActionSelectStreams": "Выбрать дорожку", + "videoActionSetSpeed": "Скорость вопспроизведения", + "videoActionSettings": "Настройки", + + "entryInfoActionEditDate": "Изменить дату и время", + "entryInfoActionRemoveMetadata": "Удалить метаданные", + + "filterFavouriteLabel": "Избранное", + "filterLocationEmptyLabel": "Без местоположения", + "filterTagEmptyLabel": "Без тегов", + "filterTypeAnimatedLabel": "GIF", + "filterTypeMotionPhotoLabel": "Живое фото", + "filterTypePanoramaLabel": "Панорама", + "filterTypeRawLabel": "RAW", + "filterTypeSphericalVideoLabel": "360° видео", + "filterTypeGeotiffLabel": "GeoTIFF", + "filterMimeImageLabel": "Изображение", + "filterMimeVideoLabel": "Видео", + + "coordinateFormatDms": "Градусы, минуты и секунды", + "coordinateFormatDecimal": "Десятичные градусы", + "coordinateDms": "{coordinate} {direction}", + "coordinateDmsNorth": "с. ш.", + "coordinateDmsSouth": "ю. ш.", + "coordinateDmsEast": "в. д.", + "coordinateDmsWest": "з. д.", + + "unitSystemMetric": "Метрические", + "unitSystemImperial": "Имперские", + + "videoLoopModeNever": "Никогда", + "videoLoopModeShortOnly": "Только для коротких видео", + "videoLoopModeAlways": "Всегда", + + "mapStyleGoogleNormal": "Google Карты", + "mapStyleGoogleHybrid": "Google Карты (Гибридный)", + "mapStyleGoogleTerrain": "Google Карты (Местность)", + "mapStyleOsmHot": "Команда гуманитарной картопомощи", + "mapStyleStamenToner": "Stamen Toner", + "mapStyleStamenWatercolor": "Stamen Watercolor", + + "nameConflictStrategyRename": "Переименовать", + "nameConflictStrategyReplace": "Заменить", + "nameConflictStrategySkip": "Пропустить", + + "keepScreenOnNever": "Никогда", + "keepScreenOnViewerOnly": "Только в просмотрщике", + "keepScreenOnAlways": "Всегда", + + "accessibilityAnimationsRemove": "Предотвратить экранные эффекты", + "accessibilityAnimationsKeep": "Сохранить экранные эффекты", + + "albumTierNew": "Новые", + "albumTierPinned": "Закрепленные", + "albumTierSpecial": "Стандартные", + "albumTierApps": "Приложения", + "albumTierRegular": "Другие", + + "storageVolumeDescriptionFallbackPrimary": "Внутренняя память", + "storageVolumeDescriptionFallbackNonPrimary": "SD-карта", + "rootDirectoryDescription": "корень", + "otherDirectoryDescription": "“{name}” каталог", + "storageAccessDialogTitle": "Доступ к хранилищу", + "storageAccessDialogMessage": "Пожалуйста, выберите каталог {directory} на накопителе «{volume}» на следующем экране, чтобы предоставить этому приложению доступ к нему.", + "restrictedAccessDialogTitle": "Ограниченный доступ", + "restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.", + "notEnoughSpaceDialogTitle": "Недостаточно свободного места.", + "notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.", + + "unsupportedTypeDialogTitle": "Неподдерживаемые форматы", + "unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}", + + "nameConflictDialogSingleSourceMessage": "Некоторые файлы в папке назначения имеют одно и то же имя.", + "nameConflictDialogMultipleSourceMessage": "Некоторые файлы имеют одно и то же имя.", + + "addShortcutDialogLabel": "Название ярлыка", + "addShortcutButtonLabel": "СОЗДАТЬ", + + "noMatchingAppDialogTitle": "Нет подходящего приложения", + "noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.", + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}", + + "videoResumeDialogMessage": "Хотите ли вы возобновить воспроизведение на {time}?", + "videoStartOverButtonLabel": "ВОСПРОИЗВЕСТИ СНАЧАЛА", + "videoResumeButtonLabel": "ПРОДОЛЖИТЬ", + + "setCoverDialogTitle": "Установить обложку", + "setCoverDialogLatest": "Последний объект", + "setCoverDialogCustom": "Собственная", + + "hideFilterConfirmationDialogMessage": "Соответствующие фотографии и видео будут скрыты из вашей коллекции. Вы можете показать их снова в настройках в разделе «Конфиденциальность».\n\nВы уверены, что хотите их скрыть?", + + "newAlbumDialogTitle": "Новый альбом", + "newAlbumDialogNameLabel": "Название альбома", + "newAlbumDialogNameLabelAlreadyExistsHelper": "Каталог уже существует", + "newAlbumDialogStorageLabel": "Накопитель:", + + "renameAlbumDialogLabel": "Новое название", + "renameAlbumDialogLabelAlreadyExistsHelper": "Каталог уже существует", + + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот альбом и его объект?} few{Вы уверены, что хотите удалить этот альбом и его {count} объекта?} other{Вы уверены, что хотите удалить этот альбом и его {count} объектов?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить эти альбомы и их объекты?} few{Вы уверены, что хотите удалить эти альбомы и их {count} объекта?} other{Вы уверены, что хотите удалить эти альбомы и их {count} объектов?}}", + + "exportEntryDialogFormat": "Формат:", + + "renameEntryDialogLabel": "Новое название", + + "editEntryDateDialogTitle": "Дата и время", + "editEntryDateDialogSet": "Задать", + "editEntryDateDialogShift": "Сдвиг", + "editEntryDateDialogExtractFromTitle": "Извлечь из названия", + "editEntryDateDialogClear": "Очистить", + "editEntryDateDialogFieldSelection": "Выбор поля", + "editEntryDateDialogHours": "Часов", + "editEntryDateDialogMinutes": "Минут", + + "removeEntryMetadataDialogTitle": "Удаление метаданных", + "removeEntryMetadataDialogMore": "Дополнительно", + + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?", + + "videoSpeedDialogLabel": "Скорость воспроизведения", + + "videoStreamSelectionDialogVideo": "Видео", + "videoStreamSelectionDialogAudio": "Аудио", + "videoStreamSelectionDialogText": "Субтитры", + "videoStreamSelectionDialogOff": "Отключено", + "videoStreamSelectionDialogTrack": "Дорожка", + "videoStreamSelectionDialogNoSelection": "Других дорожек нет.", + + "genericSuccessFeedback": "Выполнено!", + "genericFailureFeedback": "Не удалось", + + "menuActionSort": "Сортировка", + "menuActionGroup": "Группировка", + "menuActionSelect": "Выбрать", + "menuActionSelectAll": "Выбрать все", + "menuActionSelectNone": "Снять выделение", + "menuActionMap": "Карта", + "menuActionStats": "Статистика", + + "aboutPageTitle": "О нас", + "aboutLinkSources": "Исходники", + "aboutLinkLicense": "Лицензия", + "aboutLinkPolicy": "Политика конфиденциальности", + + "aboutUpdate": "Доступна новая версия", + "aboutUpdateLinks1": "Новая версия Aves доступна на", + "aboutUpdateLinks2": "и", + "aboutUpdateLinks3": ".", + "aboutUpdateGitHub": "GitHub", + "aboutUpdateGooglePlay": "Play Маркет", + + "aboutBug": "Отчет об ошибке", + "aboutBugSaveLogInstruction": "Сохраните логи приложения в файл", + "aboutBugSaveLogButton": "Сохранить", + "aboutBugCopyInfoInstruction": "Скопируйте системную информацию", + "aboutBugCopyInfoButton": "Скопировать", + "aboutBugReportInstruction": "Отправьте отчёт об ошибке на GitHub вместе с логами и системной информацией", + "aboutBugReportButton": "Отправить", + + "aboutCredits": "Благодарности", + "aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из", + "aboutCreditsWorldAtlas2": "под лицензией ISC.", + "aboutCreditsTranslators": "Переводчики:", + + "aboutLicenses": "Лицензии с открытым исходным кодом", + "aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.", + "aboutLicensesAndroidLibraries": "Библиотеки Android", + "aboutLicensesFlutterPlugins": "Плагины Flutter", + "aboutLicensesFlutterPackages": "Пакеты Flutter", + "aboutLicensesDartPackages": "Пакеты Dart", + "aboutLicensesShowAllButtonLabel": "Показать все лицензии", + + "policyPageTitle": "Политика конфиденциальности", + + "collectionPageTitle": "Коллекция", + "collectionPickPageTitle": "Выбрать", + "collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов}}", + + "collectionActionShowTitleSearch": "Показать фильтр заголовка", + "collectionActionHideTitleSearch": "Скрыть фильтр заголовка", + "collectionActionAddShortcut": "Добавить ярлык", + "collectionActionCopy": "Скопировать в альбом", + "collectionActionMove": "Переместить в альбом", + "collectionActionRescan": "Пересканировать", + "collectionActionEdit": "Изменить", + + "collectionSearchTitlesHintText": "Поиск заголовков", + + "collectionSortTitle": "Сортировка", + "collectionSortDate": "По дате", + "collectionSortSize": "По размеру", + "collectionSortName": "По имени альбома и файла", + + "collectionGroupTitle": "Группировка", + "collectionGroupAlbum": "По альбому", + "collectionGroupMonth": "По месяцу", + "collectionGroupDay": "По дню", + "collectionGroupNone": "Не группировать", + + "sectionUnknown": "Неизвестно", + "dateToday": "Сегодня", + "dateYesterday": "Вчера", + "dateThisMonth": "В этом месяце", + "collectionDeleteFailureFeedback": "{count, plural, =1{Не удалось удалить 1 объект} few{Не удалось удалить {count} объекта} other{Не удалось удалить {count} объектов}}", + "collectionCopyFailureFeedback": "{count, plural, =1{Не удалось скопировать 1 объект} few{Не удалось скопировать {count} объекта} other{Не удалось скопировать {count} объектов}}", + "collectionMoveFailureFeedback": "{count, plural, =1{Не удалось переместить 1 объект} few{Не удалось переместить {count} объекта} other{Не удалось переместить {count} объектов}}", + "collectionEditFailureFeedback": "{count, plural, =1{Не удалось изменить 1 объект} few{Не удалось изменить {count} объекта} other{Не удалось изменить {count} объектов}}", + "collectionExportFailureFeedback": "{count, plural, =1{Не удалось экспортировать 1 страницу} few{Не удалось экспортировать {count} страницы} other{Не удалось экспортировать {count} страниц}}", + "collectionCopySuccessFeedback": "{count, plural, =1{Скопирован 1 объект} few{Скопировано {count} объекта} other{Скопировано {count} объектов}}", + "collectionMoveSuccessFeedback": "{count, plural, =1{Перемещен 1 объект} few{Перемещено {count} объекта} other{Перемещено {count} объектов}}", + "collectionEditSuccessFeedback": "{count, plural, =1{Изменён 1 объект} few{Изменено {count} объекта} other{Изменено {count} объектов}}", + + "collectionEmptyFavourites": "Нет избранных", + "collectionEmptyVideos": "Нет видео", + "collectionEmptyImages": "Нет изображений", + + "collectionSelectSectionTooltip": "Выбрать раздел", + "collectionDeselectSectionTooltip": "Снять выбор с раздела", + + "drawerCollectionAll": "Вся коллекция", + "drawerCollectionFavourites": "Избранное", + "drawerCollectionImages": "Изображения", + "drawerCollectionVideos": "Видео", + "drawerCollectionMotionPhotos": "Живые фото", + "drawerCollectionPanoramas": "Панорамы", + "drawerCollectionRaws": "RAW", + "drawerCollectionSphericalVideos": "360° видео", + + "chipSortTitle": "Сортировка", + "chipSortDate": "По дате", + "chipSortName": "По названию", + "chipSortCount": "По количеству объектов", + + "albumGroupTitle": "Группировка", + "albumGroupTier": "По уровню", + "albumGroupVolume": "По накопителю", + "albumGroupNone": "Не группировать", + + "albumPickPageTitleCopy": "Копировать в альбом", + "albumPickPageTitleExport": "Экспорт в альбом", + "albumPickPageTitleMove": "Переместить в альбом", + "albumPickPageTitlePick": "Выберите альбом", + + "albumCamera": "Камера", + "albumDownload": "Загрузки", + "albumScreenshots": "Скриншоты", + "albumScreenRecordings": "Записи экрана", + "albumVideoCaptures": "Видеозаписи", + + "albumPageTitle": "Альбомы", + "albumEmpty": "Нет альбомов", + "createAlbumTooltip": "Создать альбом", + "createAlbumButtonLabel": "СОЗДАТЬ", + "newFilterBanner": "новый", + + "countryPageTitle": "Страны", + "countryEmpty": "Нет стран", + + "tagPageTitle": "Теги", + "tagEmpty": "Нет тегов", + + "searchCollectionFieldHint": "Поиск по коллекции", + "searchSectionRecent": "Недавние", + "searchSectionAlbums": "Альбомы", + "searchSectionCountries": "Страны", + "searchSectionPlaces": "Локации", + "searchSectionTags": "Теги", + + "settingsPageTitle": "Настройки", + "settingsSystemDefault": "Система", + "settingsDefault": "По умолчанию", + + "settingsActionExport": "Экспорт", + "settingsActionImport": "Импорт", + + "settingsSectionNavigation": "Навигация", + "settingsHome": "Домашний каталог", + "settingsKeepScreenOnTile": "Держать экран включенным", + "settingsKeepScreenOnTitle": "Держать экран включенным", + "settingsDoubleBackExit": "Дважды нажмите «назад», чтобы выйти", + + "settingsNavigationDrawerTile": "Навигационное меню", + "settingsNavigationDrawerEditorTitle": "Навигационное меню", + "settingsNavigationDrawerBanner": "Нажмите и удерживайте, чтобы переместить и изменить порядок пунктов меню.", + "settingsNavigationDrawerTabTypes": "Типы", + "settingsNavigationDrawerTabAlbums": "Альбомы", + "settingsNavigationDrawerTabPages": "Страницы", + "settingsNavigationDrawerAddAlbum": "Добавить альбом", + + "settingsSectionThumbnails": "Эскизы", + "settingsThumbnailShowLocationIcon": "Показать значок местоположения", + "settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото", + "settingsThumbnailShowRawIcon": "Показать значок RAW-файла", + "settingsThumbnailShowVideoDuration": "Показывать продолжительность видео", + + "settingsCollectionQuickActionsTile": "Быстрые действия", + "settingsCollectionQuickActionEditorTitle": "Быстрые действия", + "settingsCollectionQuickActionTabBrowsing": "Просмотр", + "settingsCollectionQuickActionTabSelecting": "Выбор", + "settingsCollectionBrowsingQuickActionEditorBanner": "Коснитесь и удерживайте для перемещения кнопок и выбора действий, отображаемых при просмотре объектов.", + "settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.", + + "settingsSectionViewer": "Просмотрщик", + "settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии", + "settingsViewerShowMinimap": "Показать миникарту", + "settingsViewerShowInformation": "Показывать информацию", + "settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.", + "settingsViewerShowShootingDetails": "Показать детали съёмки", + "settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия", + "settingsViewerUseCutout": "Использовать область выреза", + "settingsImageBackground": "Фон изображения", + + "settingsViewerQuickActionsTile": "Быстрые действия", + "settingsViewerQuickActionEditorTitle": "Быстрые действия", + "settingsViewerQuickActionEditorBanner": "Нажмите и удерживайте для перемещения кнопок и выбора действий, отображаемых в просмотрщике.", + "settingsViewerQuickActionEditorDisplayedButtons": "Отображаемые кнопки", + "settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки", + "settingsViewerQuickActionEmpty": "Нет кнопок", + + "settingsVideoPageTitle": "Настройки видео", + "settingsSectionVideo": "Видео", + "settingsVideoShowVideos": "Показывать видео", + "settingsVideoEnableHardwareAcceleration": "Аппаратное ускорение", + "settingsVideoEnableAutoPlay": "Автозапуск воспроизведения", + "settingsVideoLoopModeTile": "Цикличный режим", + "settingsVideoLoopModeTitle": "Цикличный режим", + "settingsVideoQuickActionsTile": "Быстрые действия для видео", + "settingsVideoQuickActionEditorTitle": "Быстрые действия", + + "settingsSubtitleThemeTile": "Субтитры", + "settingsSubtitleThemeTitle": "Субтитры", + "settingsSubtitleThemeSample": "Это образец.", + "settingsSubtitleThemeTextAlignmentTile": "Выравнивание текста", + "settingsSubtitleThemeTextAlignmentTitle": "Выравнивание текста", + "settingsSubtitleThemeTextSize": "Размер текста", + "settingsSubtitleThemeShowOutline": "Показать контур и тень", + "settingsSubtitleThemeTextColor": "Цвет текста", + "settingsSubtitleThemeTextOpacity": "Непрозрачность текста", + "settingsSubtitleThemeBackgroundColor": "Цвет фона", + "settingsSubtitleThemeBackgroundOpacity": "Непрозрачность фона", + "settingsSubtitleThemeTextAlignmentLeft": "По левой стороне", + "settingsSubtitleThemeTextAlignmentCenter": "По центру", + "settingsSubtitleThemeTextAlignmentRight": "По правой стороне", + + "settingsSectionPrivacy": "Конфиденциальность", + "settingsAllowInstalledAppAccess": "Разрешить доступ к библиотеке приложения", + "settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбома", + "settingsAllowErrorReporting": "Разрешить анонимную отправку логов", + "settingsSaveSearchHistory": "Сохранять историю поиска", + + "settingsHiddenFiltersTile": "Скрытые фильтры", + "settingsHiddenFiltersTitle": "Скрытые фильтры", + "settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.", + "settingsHiddenFiltersEmpty": "Нет скрытых фильтров", + + "settingsHiddenPathsTile": "Скрытые каталоги", + "settingsHiddenPathsTitle": "Скрытые каталоги", + "settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.", + "settingsHiddenPathsEmpty": "Нет скрытых каталогов", + "addPathTooltip": "Добавить каталог", + + "settingsStorageAccessTile": "Доступ к хранилищу", + "settingsStorageAccessTitle": "Доступ к хранилищу", + "settingsStorageAccessBanner": "Некоторые каталоги требуют обязательного предоставления доступа для изменения файлов в них. Вы можете просмотреть здесь каталоги, к которым вы ранее предоставили доступ.", + "settingsStorageAccessEmpty": "Нет прав доступа", + "settingsStorageAccessRevokeTooltip": "Отменить", + + "settingsSectionAccessibility": "Специальные возможности", + "settingsRemoveAnimationsTile": "Удалить анимацию", + "settingsRemoveAnimationsTitle": "Удалить анимацию", + "settingsTimeToTakeActionTile": "Время на выполнение действия", + "settingsTimeToTakeActionTitle": "Время на выполнение действия", + + "settingsSectionLanguage": "Язык и форматы", + "settingsLanguage": "Язык", + "settingsCoordinateFormatTile": "Формат координат", + "settingsCoordinateFormatTitle": "Формат координат", + "settingsUnitSystemTile": "Единицы измерения", + "settingsUnitSystemTitle": "Единицы измерения", + + "statsPageTitle": "Статистика", + "statsWithGps": "{count, plural, =1{1 объект с местоположением} few{{count} объекта с местоположением} other{{count} объектов с местоположением}}", + "statsTopCountries": "Топ стран", + "statsTopPlaces": "Топ локаций", + "statsTopTags": "Топ тегов", + + "viewerOpenPanoramaButtonLabel": "ОТКРЫТЬ ПАНОРАМУ", + "viewerErrorUnknown": "Упс!", + "viewerErrorDoesNotExist": "Файл больше не существует.", + + "viewerInfoPageTitle": "Информация", + "viewerInfoBackToViewerTooltip": "Вернуться к просмотрщику", + + "viewerInfoUnknown": "неизвестный", + "viewerInfoLabelTitle": "Название", + "viewerInfoLabelDate": "Дата", + "viewerInfoLabelResolution": "Разрешение", + "viewerInfoLabelSize": "Размер", + "viewerInfoLabelUri": "Идентификатор", + "viewerInfoLabelPath": "Расположение", + "viewerInfoLabelDuration": "Продолжительность", + "viewerInfoLabelOwner": "Владелец", + "viewerInfoLabelCoordinates": "Координаты", + "viewerInfoLabelAddress": "Адрес", + + "mapStyleTitle": "Стиль карты", + "mapStyleTooltip": "Выберите стиль карты", + "mapZoomInTooltip": "Увеличить", + "mapZoomOutTooltip": "Уменьшить", + "mapPointNorthUpTooltip": "Повернуть на север", + "mapAttributionOsmHot": "Данные карты © [OpenStreetMap](https://www.openstreetmap.org/copyright) помощники • Плитки [HOT](https://www.hotosm.org/) • Размещена на [OSM France](https://openstreetmap.fr/)", + "mapAttributionStamen": "Данные карты © [OpenStreetMap](https://www.openstreetmap.org/copyright) помощники • Плитки [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", + "openMapPageTooltip": "Просмотреть на странице карты", + "mapEmptyRegion": "Нет изображений в этом регионе", + + "viewerInfoOpenEmbeddedFailureFeedback": "Не удалось извлечь встроенные данные", + "viewerInfoOpenLinkText": "Открыть", + "viewerInfoViewXmlLinkText": "Просмотр XML", + + "viewerInfoSearchFieldLabel": "Поиск метаданных", + "viewerInfoSearchEmpty": "Нет подходящих ключей", + "viewerInfoSearchSuggestionDate": "Дата и время", + "viewerInfoSearchSuggestionDescription": "Описание", + "viewerInfoSearchSuggestionDimensions": "Измерения", + "viewerInfoSearchSuggestionResolution": "Разрешение", + "viewerInfoSearchSuggestionRights": "Права", + + "panoramaEnableSensorControl": "Включить сенсорное управление", + "panoramaDisableSensorControl": "Отключить сенсорное управление", + + "sourceViewerPageTitle": "Источник" +} diff --git a/lib/main.dart b/lib/main_common.dart similarity index 90% rename from lib/main.dart rename to lib/main_common.dart index 1bbc8c2f3..98d2790f0 100644 --- a/lib/main.dart +++ b/lib/main_common.dart @@ -1,10 +1,11 @@ import 'dart:isolate'; +import 'package:aves/app_flavor.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:flutter/material.dart'; -void main() { +void mainCommon(AppFlavor flavor) { // HttpClient.enableTimelineLogging = true; // enable network traffic logging // debugPrintGestureArenaDiagnostics = true; @@ -27,5 +28,5 @@ void main() { reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last); }).sendPort); - runApp(const AvesApp()); + runApp(AvesApp(flavor: flavor)); } diff --git a/lib/main_izzy.dart b/lib/main_izzy.dart new file mode 100644 index 000000000..4d3e27549 --- /dev/null +++ b/lib/main_izzy.dart @@ -0,0 +1,6 @@ +import 'package:aves/app_flavor.dart'; +import 'package:aves/main_common.dart'; + +void main() { + mainCommon(AppFlavor.izzy); +} diff --git a/lib/main_play.dart b/lib/main_play.dart new file mode 100644 index 000000000..503d95000 --- /dev/null +++ b/lib/main_play.dart @@ -0,0 +1,6 @@ +import 'package:aves/app_flavor.dart'; +import 'package:aves/main_common.dart'; + +void main() { + mainCommon(AppFlavor.play); +} diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 83a43d636..24d604796 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -1,6 +1,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; enum ChipSetAction { // general @@ -9,20 +9,50 @@ enum ChipSetAction { select, selectAll, selectNone, + // browsing + search, createAlbum, - // all or filter selection + // browsing or selecting map, stats, - // single/multiple filter selection + // selecting (single/multiple filters) delete, hide, pin, unpin, - // single filter selection + // selecting (single filter) rename, setCover, } +class ChipSetActions { + static const general = [ + ChipSetAction.sort, + ChipSetAction.group, + ChipSetAction.select, + ChipSetAction.selectAll, + ChipSetAction.selectNone, + ]; + + static const browsing = [ + ChipSetAction.search, + ChipSetAction.createAlbum, + ChipSetAction.map, + ChipSetAction.stats, + ]; + + static const selection = [ + ChipSetAction.setCover, + ChipSetAction.pin, + ChipSetAction.unpin, + ChipSetAction.delete, + ChipSetAction.rename, + ChipSetAction.hide, + ChipSetAction.map, + ChipSetAction.stats, + ]; +} + extension ExtraChipSetAction on ChipSetAction { String getText(BuildContext context) { switch (this) { @@ -37,13 +67,17 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.menuActionSelectAll; case ChipSetAction.selectNone: return context.l10n.menuActionSelectNone; + // browsing + case ChipSetAction.search: + return MaterialLocalizations.of(context).searchFieldLabel; + case ChipSetAction.createAlbum: + return context.l10n.chipActionCreateAlbum; + // browsing or selecting case ChipSetAction.map: return context.l10n.menuActionMap; case ChipSetAction.stats: return context.l10n.menuActionStats; - case ChipSetAction.createAlbum: - return context.l10n.chipActionCreateAlbum; - // single/multiple filters + // selecting (single/multiple filters) case ChipSetAction.delete: return context.l10n.chipActionDelete; case ChipSetAction.hide: @@ -52,7 +86,7 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.chipActionPin; case ChipSetAction.unpin: return context.l10n.chipActionUnpin; - // single filter + // selecting (single filter) case ChipSetAction.rename: return context.l10n.chipActionRename; case ChipSetAction.setCover: @@ -77,13 +111,17 @@ extension ExtraChipSetAction on ChipSetAction { return AIcons.selected; case ChipSetAction.selectNone: return AIcons.unselected; + // browsing + case ChipSetAction.search: + return AIcons.search; + case ChipSetAction.createAlbum: + return AIcons.add; + // browsing or selecting case ChipSetAction.map: return AIcons.map; case ChipSetAction.stats: return AIcons.stats; - case ChipSetAction.createAlbum: - return AIcons.add; - // single/multiple filters + // selecting (single/multiple filters) case ChipSetAction.delete: return AIcons.delete; case ChipSetAction.hide: @@ -92,7 +130,7 @@ extension ExtraChipSetAction on ChipSetAction { return AIcons.pin; case ChipSetAction.unpin: return AIcons.unpin; - // single filter + // selecting (single filter) case ChipSetAction.rename: return AIcons.rename; case ChipSetAction.setCover: diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index b156e3b88..f85e69d6f 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -1,6 +1,6 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; enum EntrySetAction { // general @@ -9,20 +9,43 @@ enum EntrySetAction { select, selectAll, selectNone, - // all + // browsing + searchCollection, + toggleTitleSearch, addShortcut, - // all or entry selection + // browsing or selecting map, stats, - // entry selection + // selecting share, delete, copy, move, rescan, + rotateCCW, + rotateCW, + flip, + editDate, + removeMetadata, } class EntrySetActions { + static const general = [ + EntrySetAction.sort, + EntrySetAction.group, + EntrySetAction.select, + EntrySetAction.selectAll, + EntrySetAction.selectNone, + ]; + + static const browsing = [ + EntrySetAction.searchCollection, + EntrySetAction.toggleTitleSearch, + EntrySetAction.addShortcut, + EntrySetAction.map, + EntrySetAction.stats, + ]; + static const selection = [ EntrySetAction.share, EntrySetAction.delete, @@ -31,6 +54,7 @@ class EntrySetActions { EntrySetAction.rescan, EntrySetAction.map, EntrySetAction.stats, + // editing actions are in their subsection ]; } @@ -48,15 +72,20 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.menuActionSelectAll; case EntrySetAction.selectNone: return context.l10n.menuActionSelectNone; - // all + // browsing + case EntrySetAction.searchCollection: + return MaterialLocalizations.of(context).searchFieldLabel; + case EntrySetAction.toggleTitleSearch: + // different data depending on toggle state + return context.l10n.collectionActionShowTitleSearch; case EntrySetAction.addShortcut: return context.l10n.collectionActionAddShortcut; - // all or entry selection + // browsing or selecting case EntrySetAction.map: return context.l10n.menuActionMap; case EntrySetAction.stats: return context.l10n.menuActionStats; - // entry selection + // selecting case EntrySetAction.share: return context.l10n.entryActionShare; case EntrySetAction.delete: @@ -67,6 +96,16 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.collectionActionMove; case EntrySetAction.rescan: return context.l10n.collectionActionRescan; + case EntrySetAction.rotateCCW: + return context.l10n.entryActionRotateCCW; + case EntrySetAction.rotateCW: + return context.l10n.entryActionRotateCW; + case EntrySetAction.flip: + return context.l10n.entryActionFlip; + case EntrySetAction.editDate: + return context.l10n.entryInfoActionEditDate; + case EntrySetAction.removeMetadata: + return context.l10n.entryInfoActionRemoveMetadata; } } @@ -87,15 +126,20 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.selected; case EntrySetAction.selectNone: return AIcons.unselected; - // all + // browsing + case EntrySetAction.searchCollection: + return AIcons.search; + case EntrySetAction.toggleTitleSearch: + // different data depending on toggle state + return AIcons.filter; case EntrySetAction.addShortcut: return AIcons.addShortcut; - // all or entry selection + // browsing or selecting case EntrySetAction.map: return AIcons.map; case EntrySetAction.stats: return AIcons.stats; - // entry selection + // selecting case EntrySetAction.share: return AIcons.share; case EntrySetAction.delete: @@ -106,6 +150,16 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.move; case EntrySetAction.rescan: return AIcons.refresh; + case EntrySetAction.rotateCCW: + return AIcons.rotateLeft; + case EntrySetAction.rotateCW: + return AIcons.rotateRight; + case EntrySetAction.flip: + return AIcons.flip; + case EntrySetAction.editDate: + return AIcons.date; + case EntrySetAction.removeMetadata: + return AIcons.clear; } } } diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart index dcddaf60b..627640bb5 100644 --- a/lib/model/actions/video_actions.dart +++ b/lib/model/actions/video_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; enum VideoAction { captureFrame, + playOutside, replay10, skip10, selectStreams, @@ -21,6 +22,7 @@ class VideoActions { VideoAction.selectStreams, VideoAction.replay10, VideoAction.skip10, + VideoAction.playOutside, VideoAction.settings, ]; } @@ -30,6 +32,8 @@ extension ExtraVideoAction on VideoAction { switch (this) { case VideoAction.captureFrame: return context.l10n.videoActionCaptureFrame; + case VideoAction.playOutside: + return context.l10n.entryActionOpen; case VideoAction.replay10: return context.l10n.videoActionReplay10; case VideoAction.skip10: @@ -54,6 +58,8 @@ extension ExtraVideoAction on VideoAction { switch (this) { case VideoAction.captureFrame: return AIcons.captureFrame; + case VideoAction.playOutside: + return AIcons.openOutside; case VideoAction.replay10: return AIcons.replay10; case VideoAction.skip10: diff --git a/lib/model/covers.dart b/lib/model/covers.dart index 2b3256705..d10e67ca7 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -15,7 +15,7 @@ class Covers with ChangeNotifier { Covers._private(); Future init() async { - _rows = await metadataDb.loadCovers(); + _rows = await metadataDb.loadAllCovers(); } int get count => _rows.length; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index a3bd9f7d3..6357ae9a7 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -18,6 +18,7 @@ import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; @@ -548,14 +549,6 @@ class AvesEntry { }.whereNotNull().where((v) => v.isNotEmpty).join(', '); } - bool search(String query) => { - bestTitle, - _catalogMetadata?.xmpSubjects, - _addressDetails?.countryName, - _addressDetails?.adminArea, - _addressDetails?.locality, - }.any((s) => s != null && s.toUpperCase().contains(query)); - Future _applyNewFields(Map newFields, {required bool persist}) async { final oldDateModifiedSecs = this.dateModifiedSecs; final oldRotationDegrees = this.rotationDegrees; @@ -635,6 +628,16 @@ class AvesEntry { } Future editDate(DateModifier modifier) async { + if (modifier.action == DateEditAction.extractFromTitle) { + final _title = bestTitle; + if (_title == null) return false; + final date = parseUnknownDateFormat(_title); + if (date == null) { + await reportService.recordError('failed to parse date from title=$_title', null); + return false; + } + modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); + } final newFields = await metadataEditService.editDate(this, modifier); return newFields.isNotEmpty; } diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index 18123a753..4a8e225e8 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -12,7 +12,7 @@ class Favourites with ChangeNotifier { Favourites._private(); Future init() async { - _rows = await metadataDb.loadFavourites(); + _rows = await metadataDb.loadAllFavourites(); } int get count => _rows.length; diff --git a/lib/model/filters/coordinate.dart b/lib/model/filters/coordinate.dart index fed0f5702..5f733ffc3 100644 --- a/lib/model/filters/coordinate.dart +++ b/lib/model/filters/coordinate.dart @@ -4,8 +4,10 @@ import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/geo_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; @@ -37,8 +39,9 @@ class CoordinateFilter extends CollectionFilter { @override EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng); - String _formatBounds(CoordinateFormat format) { + String _formatBounds(AppLocalizations l10n, CoordinateFormat format) { String s(LatLng latLng) => format.format( + l10n, latLng, minuteSecondPadding: minuteSecondPadding, dmsSecondDecimals: 0, @@ -47,10 +50,10 @@ class CoordinateFilter extends CollectionFilter { } @override - String get universalLabel => _formatBounds(CoordinateFormat.decimal); + String get universalLabel => _formatBounds(lookupAppLocalizations(AppLocalizations.supportedLocales.first), CoordinateFormat.decimal); @override - String getLabel(BuildContext context) => _formatBounds(context.read().coordinateFormat); + String getLabel(BuildContext context) => _formatBounds(context.l10n, context.read().coordinateFormat); @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size); diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 7fc08a231..0ffb1b86a 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -31,29 +31,33 @@ abstract class CollectionFilter extends Equatable implements Comparable) { - final type = jsonMap['type']; - switch (type) { - case AlbumFilter.type: - return AlbumFilter.fromMap(jsonMap); - case CoordinateFilter.type: - return CoordinateFilter.fromMap(jsonMap); - case FavouriteFilter.type: - return FavouriteFilter.instance; - case LocationFilter.type: - return LocationFilter.fromMap(jsonMap); - case MimeFilter.type: - return MimeFilter.fromMap(jsonMap); - case PathFilter.type: - return PathFilter.fromMap(jsonMap); - case QueryFilter.type: - return QueryFilter.fromMap(jsonMap); - case TagFilter.type: - return TagFilter.fromMap(jsonMap); - case TypeFilter.type: - return TypeFilter.fromMap(jsonMap); + try { + final jsonMap = jsonDecode(jsonString); + if (jsonMap is Map) { + final type = jsonMap['type']; + switch (type) { + case AlbumFilter.type: + return AlbumFilter.fromMap(jsonMap); + case CoordinateFilter.type: + return CoordinateFilter.fromMap(jsonMap); + case FavouriteFilter.type: + return FavouriteFilter.instance; + case LocationFilter.type: + return LocationFilter.fromMap(jsonMap); + case MimeFilter.type: + return MimeFilter.fromMap(jsonMap); + case PathFilter.type: + return PathFilter.fromMap(jsonMap); + case QueryFilter.type: + return QueryFilter.fromMap(jsonMap); + case TagFilter.type: + return TagFilter.fromMap(jsonMap); + case TypeFilter.type: + return TypeFilter.fromMap(jsonMap); + } } + } catch (error, stack) { + debugPrint('failed to parse filter from json=$jsonString error=$error\n$stack'); } debugPrint('failed to parse filter from json=$jsonString'); return null; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index f4d9497eb..b7d3bd414 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -11,15 +12,15 @@ class QueryFilter extends CollectionFilter { static final RegExp exactRegex = RegExp('^"(.*)"\$'); final String query; - final bool colorful; + final bool colorful, live; late final EntryFilter _test; @override - List get props => [query]; + List get props => [query, live]; - QueryFilter(this.query, {this.colorful = true}) { + QueryFilter(this.query, {this.colorful = true, this.live = false}) { var upQuery = query.toUpperCase(); - if (upQuery.startsWith('ID=')) { + if (upQuery.startsWith('ID:')) { final id = int.tryParse(upQuery.substring(3)); _test = (entry) => entry.contentId == id; return; @@ -37,7 +38,9 @@ class QueryFilter extends CollectionFilter { upQuery = matches.first.group(1)!; } - _test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery); + // default to title search + bool testTitle(AvesEntry entry) => entry.bestTitle?.toUpperCase().contains(upQuery) == true; + _test = not ? (entry) => !testTitle(entry) : testTitle; } QueryFilter.fromMap(Map json) diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 97e548d6e..351d441ec 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -8,6 +8,7 @@ enum MetadataField { enum DateEditAction { set, shift, + extractFromTitle, clear, } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 51f95d755..df3750b5b 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -7,6 +7,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata_db_upgrade.dart'; +import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -25,7 +26,7 @@ abstract class MetadataDb { Future clearEntries(); - Future> loadEntries(); + Future> loadAllEntries(); Future saveEntries(Iterable entries); @@ -43,7 +44,7 @@ abstract class MetadataDb { Future clearMetadataEntries(); - Future> loadMetadataEntries(); + Future> loadAllMetadataEntries(); Future saveMetadata(Set metadataEntries); @@ -53,7 +54,7 @@ abstract class MetadataDb { Future clearAddresses(); - Future> loadAddresses(); + Future> loadAllAddresses(); Future saveAddresses(Set addresses); @@ -63,7 +64,7 @@ abstract class MetadataDb { Future clearFavourites(); - Future> loadFavourites(); + Future> loadAllFavourites(); Future addFavourites(Iterable rows); @@ -75,13 +76,27 @@ abstract class MetadataDb { Future clearCovers(); - Future> loadCovers(); + Future> loadAllCovers(); Future addCovers(Iterable rows); Future updateCoverEntryId(int oldId, CoverRow row); Future removeCovers(Set filters); + + // video playback + + Future clearVideoPlayback(); + + Future> loadAllVideoPlayback(); + + Future loadVideoPlayback(int? contentId); + + Future addVideoPlayback(Set rows); + + Future updateVideoPlaybackId(int oldId, int? newId); + + Future removeVideoPlayback(Set contentIds); } class SqfliteMetadataDb implements MetadataDb { @@ -95,6 +110,7 @@ class SqfliteMetadataDb implements MetadataDb { static const addressTable = 'address'; static const favouriteTable = 'favourites'; static const coverTable = 'covers'; + static const videoPlaybackTable = 'videoPlayback'; @override Future init() async { @@ -146,9 +162,13 @@ class SqfliteMetadataDb implements MetadataDb { 'filter TEXT PRIMARY KEY' ', contentId INTEGER' ')'); + await db.execute('CREATE TABLE $videoPlaybackTable(' + 'contentId INTEGER PRIMARY KEY' + ', resumeTimeMillis INTEGER' + ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 4, + version: 5, ); } @@ -183,6 +203,7 @@ class SqfliteMetadataDb implements MetadataDb { if (!metadataOnly) { batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs); + batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs); } }); await batch.commit(noResult: true); @@ -194,11 +215,11 @@ class SqfliteMetadataDb implements MetadataDb { Future clearEntries() async { final db = await _database; final count = await db.delete(entryTable, where: '1'); - debugPrint('$runtimeType clearEntries deleted $count entries'); + debugPrint('$runtimeType clearEntries deleted $count rows'); } @override - Future> loadEntries() async { + Future> loadAllEntries() async { final db = await _database; final maps = await db.query(entryTable); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); @@ -252,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb { Future clearDates() async { final db = await _database; final count = await db.delete(dateTakenTable, where: '1'); - debugPrint('$runtimeType clearDates deleted $count entries'); + debugPrint('$runtimeType clearDates deleted $count rows'); } @override @@ -269,11 +290,11 @@ class SqfliteMetadataDb implements MetadataDb { Future clearMetadataEntries() async { final db = await _database; final count = await db.delete(metadataTable, where: '1'); - debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); + debugPrint('$runtimeType clearMetadataEntries deleted $count rows'); } @override - Future> loadMetadataEntries() async { + Future> loadAllMetadataEntries() async { final db = await _database; final maps = await db.query(metadataTable); final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); @@ -330,11 +351,11 @@ class SqfliteMetadataDb implements MetadataDb { Future clearAddresses() async { final db = await _database; final count = await db.delete(addressTable, where: '1'); - debugPrint('$runtimeType clearAddresses deleted $count entries'); + debugPrint('$runtimeType clearAddresses deleted $count rows'); } @override - Future> loadAddresses() async { + Future> loadAllAddresses() async { final db = await _database; final maps = await db.query(addressTable); final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList(); @@ -376,11 +397,11 @@ class SqfliteMetadataDb implements MetadataDb { Future clearFavourites() async { final db = await _database; final count = await db.delete(favouriteTable, where: '1'); - debugPrint('$runtimeType clearFavourites deleted $count entries'); + debugPrint('$runtimeType clearFavourites deleted $count rows'); } @override - Future> loadFavourites() async { + Future> loadAllFavourites() async { final db = await _database; final maps = await db.query(favouriteTable); final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet(); @@ -432,11 +453,11 @@ class SqfliteMetadataDb implements MetadataDb { Future clearCovers() async { final db = await _database; final count = await db.delete(coverTable, where: '1'); - debugPrint('$runtimeType clearCovers deleted $count entries'); + debugPrint('$runtimeType clearCovers deleted $count rows'); } @override - Future> loadCovers() async { + Future> loadAllCovers() async { final db = await _database; final maps = await db.query(coverTable); final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet(); @@ -446,6 +467,7 @@ class SqfliteMetadataDb implements MetadataDb { @override Future addCovers(Iterable rows) async { if (rows.isEmpty) return; + final db = await _database; final batch = db.batch(); rows.forEach((row) => _batchInsertCover(batch, row)); @@ -479,4 +501,71 @@ class SqfliteMetadataDb implements MetadataDb { filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()])); await batch.commit(noResult: true); } + + // video playback + + @override + Future clearVideoPlayback() async { + final db = await _database; + final count = await db.delete(videoPlaybackTable, where: '1'); + debugPrint('$runtimeType clearVideoPlayback deleted $count rows'); + } + + @override + Future> loadAllVideoPlayback() async { + final db = await _database; + final maps = await db.query(videoPlaybackTable); + final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet(); + return rows; + } + + @override + Future loadVideoPlayback(int? contentId) async { + if (contentId == null) return null; + + final db = await _database; + final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]); + if (maps.isEmpty) return null; + + return VideoPlaybackRow.fromMap(maps.first); + } + + @override + Future addVideoPlayback(Set rows) async { + if (rows.isEmpty) return; + + final db = await _database; + final batch = db.batch(); + rows.forEach((row) => _batchInsertVideoPlayback(batch, row)); + await batch.commit(noResult: true); + } + + void _batchInsertVideoPlayback(Batch batch, VideoPlaybackRow row) { + batch.insert( + videoPlaybackTable, + row.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + @override + Future updateVideoPlaybackId(int oldId, int? newId) async { + if (newId != null) { + final db = await _database; + await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]); + } else { + await removeVideoPlayback({oldId}); + } + } + + @override + Future removeVideoPlayback(Set contentIds) async { + if (contentIds.isEmpty) return; + + final db = await _database; + // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead + final batch = db.batch(); + contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id])); + await batch.commit(noResult: true); + } } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart index bb74bb11e..578934b8d 100644 --- a/lib/model/metadata_db_upgrade.dart +++ b/lib/model/metadata_db_upgrade.dart @@ -6,6 +6,7 @@ class MetadataDbUpgrader { static const entryTable = SqfliteMetadataDb.entryTable; static const metadataTable = SqfliteMetadataDb.metadataTable; static const coverTable = SqfliteMetadataDb.coverTable; + static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable; // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // on SQLite <3.25.0, bundled on older Android devices @@ -21,6 +22,9 @@ class MetadataDbUpgrader { case 3: await _upgradeFrom3(db); break; + case 4: + await _upgradeFrom4(db); + break; } oldVersion++; } @@ -109,4 +113,12 @@ class MetadataDbUpgrader { ', contentId INTEGER' ')'); } + + static Future _upgradeFrom4(Database db) async { + debugPrint('upgrading DB from v4'); + await db.execute('CREATE TABLE $videoPlaybackTable(' + 'contentId INTEGER PRIMARY KEY' + ', resumeTimeMillis INTEGER' + ')'); + } } diff --git a/lib/model/query.dart b/lib/model/query.dart new file mode 100644 index 000000000..7bb5c5241 --- /dev/null +++ b/lib/model/query.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:aves/utils/change_notifier.dart'; +import 'package:flutter/foundation.dart'; + +class Query extends ChangeNotifier { + bool _enabled = false; + + bool get enabled => _enabled; + + set enabled(bool value) { + _enabled = value; + _enabledStreamController.add(_enabled); + queryNotifier.value = ''; + notifyListeners(); + + if (_enabled) { + focusRequestNotifier.notifyListeners(); + } + } + + void toggle() => enabled = !enabled; + + final StreamController _enabledStreamController = StreamController.broadcast(); + + Stream get enabledStream => _enabledStreamController.stream; + + final AChangeNotifier focusRequestNotifier = AChangeNotifier(); + + final ValueNotifier queryNotifier = ValueNotifier(''); +} diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index b65619a06..5a65e6818 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -1,6 +1,7 @@ import 'package:aves/utils/geo_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:latlong2/latlong.dart'; import 'enums.dart'; @@ -15,12 +16,24 @@ extension ExtraCoordinateFormat on CoordinateFormat { } } - String format(LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) { + String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) { switch (this) { case CoordinateFormat.dms: - return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', '); + return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', '); case CoordinateFormat.decimal: return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); } } + + // returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] + static List toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) { + final lat = latLng.latitude; + final lng = latLng.longitude; + final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals); + final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals); + return [ + l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth), + l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast), + ]; + } } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 347d8fd5e..47db03e03 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -14,7 +14,8 @@ class SettingsDefaults { // app static const hasAcceptedTerms = false; static const canUseAnalysisService = true; - static const isErrorReportingEnabled = false; + static const isInstalledAppAccessAllowed = false; + static const isErrorReportingAllowed = false; static const mustBackTwiceToExit = true; static const keepScreenOn = KeepScreenOn.viewerOnly; static const homePage = HomePageSetting.collection; @@ -34,6 +35,9 @@ class SettingsDefaults { // collection static const collectionSectionFactor = EntryGroupFactor.month; static const collectionSortFactor = EntrySortFactor.date; + static const collectionBrowsingQuickActions = [ + EntrySetAction.searchCollection, + ]; static const collectionSelectionQuickActions = [ EntrySetAction.share, EntrySetAction.delete, diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index cccc52f81..62f76fd1c 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -41,7 +41,8 @@ class Settings extends ChangeNotifier { // app static const hasAcceptedTermsKey = 'has_accepted_terms'; static const canUseAnalysisServiceKey = 'can_use_analysis_service'; - static const isErrorReportingEnabledKey = 'is_crashlytics_enabled'; + static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed'; + static const isErrorReportingAllowedKey = 'is_crashlytics_enabled'; static const localeKey = 'locale'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const keepScreenOnKey = 'keep_screen_on'; @@ -57,6 +58,7 @@ class Settings extends ChangeNotifier { // collection static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionSortFactorKey = 'collection_sort_factor'; + static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; @@ -173,9 +175,14 @@ class Settings extends ChangeNotifier { set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue); - bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled); + // TODO TLAD use `true` for transition (it's unset in v1.5.4), but replace by `SettingsDefaults.isInstalledAppAccessAllowed` in a later release + bool get isInstalledAppAccessAllowed => getBoolOrDefault(isInstalledAppAccessAllowedKey, true); - set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue); + set isInstalledAppAccessAllowed(bool newValue) => setAndNotify(isInstalledAppAccessAllowedKey, newValue); + + bool get isErrorReportingAllowed => getBoolOrDefault(isErrorReportingAllowedKey, SettingsDefaults.isErrorReportingAllowed); + + set isErrorReportingAllowed(bool newValue) => setAndNotify(isErrorReportingAllowedKey, newValue); static const localeSeparator = '-'; @@ -265,6 +272,10 @@ class Settings extends ChangeNotifier { set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString()); + List get collectionBrowsingQuickActions => getEnumListOrDefault(collectionBrowsingQuickActionsKey, SettingsDefaults.collectionBrowsingQuickActions, EntrySetAction.values); + + set collectionBrowsingQuickActions(List newValue) => setAndNotify(collectionBrowsingQuickActionsKey, newValue.map((v) => v.toString()).toList()); + List get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values); set collectionSelectionQuickActions(List newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); @@ -563,7 +574,8 @@ class Settings extends ChangeNotifier { debugPrint('failed to import key=$key, value=$value is not a double'); } break; - case isErrorReportingEnabledKey: + case isInstalledAppAccessAllowedKey: + case isErrorReportingAllowedKey: case mustBackTwiceToExitKey: case showThumbnailLocationKey: case showThumbnailMotionPhotoKey: @@ -613,6 +625,7 @@ class Settings extends ChangeNotifier { case drawerPageBookmarksKey: case pinnedFiltersKey: case hiddenFiltersKey: + case collectionBrowsingQuickActionsKey: case collectionSelectionQuickActionsKey: case viewerQuickActionsKey: case videoQuickActionsKey: diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 76c6026fe..6f05c03b3 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -7,6 +7,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/query.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; @@ -36,7 +37,7 @@ class CollectionLens with ChangeNotifier { CollectionLens({ required this.source, - Iterable? filters, + Set? filters, this.id, this.listenToSource = true, this.fixedSelection, @@ -126,6 +127,14 @@ class CollectionLens with ChangeNotifier { _onFilterChanged(); } + void setLiveQuery(String query) { + filters.removeWhere((v) => v is QueryFilter && v.live); + if (query.isNotEmpty) { + filters.add(QueryFilter(query, live: true)); + } + _onFilterChanged(); + } + void _onFilterChanged() { _refresh(); filterChangeNotifier.notifyListeners(); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 5e026c490..2a74801c3 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -119,6 +119,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); await favourites.remove(entries); await covers.removeEntries(entries); + await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet()); entries.forEach((v) => _entryById.remove(v.contentId)); _rawEntries.removeAll(entries); @@ -157,6 +158,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM await metadataDb.updateAddressId(oldContentId, entry.addressDetails); await favourites.moveEntry(oldContentId, entry); await covers.moveEntry(oldContentId, entry); + await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId); } } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 764025ce8..e150ceb9a 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -21,7 +21,7 @@ mixin LocationMixin on SourceBase { List sortedPlaces = List.unmodifiable([]); Future loadAddresses() async { - final saved = await metadataDb.loadAddresses(); + final saved = await metadataDb.loadAllAddresses(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); onAddressMetadataChanged(); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 124de29ad..d2e246c35 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource { clearEntries(); debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); - final oldEntries = await metadataDb.loadEntries(); + final oldEntries = await metadataDb.loadAllEntries(); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!))); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 8f9922daa..3617b0212 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -15,7 +15,7 @@ mixin TagMixin on SourceBase { List sortedTags = List.unmodifiable([]); Future loadCatalogMetadata() async { - final saved = await metadataDb.loadMetadataEntries(); + final saved = await metadataDb.loadAllMetadataEntries(); final idMap = entryById; saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); onCatalogMetadataChanged(); diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index 2e830c3b0..55fdbe94b 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -22,6 +22,7 @@ import 'package:flutter/foundation.dart'; class VideoMetadataFormatter { static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + static final _anotherDatePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})'); static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final Map _codecNames = { @@ -80,19 +81,16 @@ class VideoMetadataFormatter { static Future getCatalogMetadata(AvesEntry entry) async { final mediaInfo = await getVideoMetadata(entry); - int? dateMillis; - bool isDefined(dynamic value) => value is String && value != '0'; var dateString = mediaInfo[Keys.date]; if (!isDefined(dateString)) { dateString = mediaInfo[Keys.creationTime]; } + int? dateMillis; if (isDefined(dateString)) { - final date = DateTime.tryParse(dateString); - if (date != null) { - dateMillis = date.millisecondsSinceEpoch; - } else { + dateMillis = parseVideoDate(dateString); + if (dateMillis == null) { await reportService.recordError('getCatalogMetadata failed to parse date=$dateString for mimeType=${entry.mimeType} entry=$entry', null); } } @@ -106,6 +104,33 @@ class VideoMetadataFormatter { return entry.catalogMetadata; } + static int? parseVideoDate(String dateString) { + final date = DateTime.tryParse(dateString); + if (date != null) { + return date.millisecondsSinceEpoch; + } + + // `DateTime` does not recognize: + // - `UTC 2021-05-30 19:14:21` + + final match = _anotherDatePattern.firstMatch(dateString); + if (match != null) { + final year = int.tryParse(match.group(1)!); + final month = int.tryParse(match.group(2)!); + final day = int.tryParse(match.group(3)!); + final hour = int.tryParse(match.group(4)!); + final minute = int.tryParse(match.group(5)!); + final second = int.tryParse(match.group(6)!); + + if (year != null && month != null && day != null && hour != null && minute != null && second != null) { + final date = DateTime(year, month, day, hour, minute, second, 0); + return date.millisecondsSinceEpoch; + } + } + + return null; + } + // pattern to extract optional language code suffix, e.g. 'location-eng' static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$'); diff --git a/lib/model/video_playback.dart b/lib/model/video_playback.dart new file mode 100644 index 000000000..660e4a77f --- /dev/null +++ b/lib/model/video_playback.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class VideoPlaybackRow extends Equatable { + final int contentId, resumeTimeMillis; + + @override + List get props => [contentId, resumeTimeMillis]; + + const VideoPlaybackRow({ + required this.contentId, + required this.resumeTimeMillis, + }); + + static VideoPlaybackRow? fromMap(Map map) { + return VideoPlaybackRow( + contentId: map['contentId'], + resumeTimeMillis: map['resumeTimeMillis'], + ); + } + + Map toMap() => { + 'contentId': contentId, + 'resumeTimeMillis': resumeTimeMillis, + }; +} diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index b81428f18..591d99414 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -44,7 +44,8 @@ class MimeTypes { static const aviVnd = 'video/vnd.avi'; static const mkv = 'video/x-matroska'; static const mov = 'video/quicktime'; - static const mp2t = 'video/mp2t'; // .m2ts + static const mp2t = 'video/mp2t'; // .m2ts, .ts + static const mp2ts = 'video/mp2ts'; // .ts (prefer `mp2t` when possible) static const mp4 = 'video/mp4'; static const ogv = 'video/ogg'; static const webm = 'video/webm'; @@ -67,7 +68,7 @@ class MimeTypes { static const Set _knownOpaqueImages = {heic, heif, jpeg}; - static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm}; + static const Set _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, ogv, webm}; static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index 9963efef7..795571bfb 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -7,9 +7,10 @@ import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/services/media/media_store_service.dart'; import 'package:aves/services/metadata/metadata_edit_service.dart'; import 'package:aves/services/metadata/metadata_fetch_service.dart'; -import 'package:aves/services/report_service.dart'; import 'package:aves/services/storage_service.dart'; import 'package:aves/services/window_service.dart'; +import 'package:aves_report/aves_report.dart'; +import 'package:aves_report_platform/aves_report_platform.dart'; import 'package:get_it/get_it.dart'; import 'package:path/path.dart' as p; @@ -42,7 +43,7 @@ void initPlatformServices() { getIt.registerLazySingleton(() => PlatformMediaStoreService()); getIt.registerLazySingleton(() => PlatformMetadataEditService()); getIt.registerLazySingleton(() => PlatformMetadataFetchService()); - getIt.registerLazySingleton(() => CrashlyticsReportService()); + getIt.registerLazySingleton(() => PlatformReportService()); getIt.registerLazySingleton(() => PlatformStorageService()); getIt.registerLazySingleton(() => PlatformWindowService()); } diff --git a/lib/services/global_search.dart b/lib/services/global_search.dart index d66ba2c17..2edc86528 100644 --- a/lib/services/global_search.dart +++ b/lib/services/global_search.dart @@ -47,6 +47,9 @@ Future>> _getSuggestions(dynamic args) async { if (args is Map) { final query = args['query']; final locale = args['locale']; + final use24hour = args['use24hour']; + debugPrint('getSuggestions query=$query, locale=$locale use24hour=$use24hour'); + if (query is String && locale is String) { final entries = await metadataDb.searchEntries(query, limit: 9); suggestions.addAll(entries.map((entry) { @@ -55,7 +58,7 @@ Future>> _getSuggestions(dynamic args) async { 'data': entry.uri, 'mimeType': entry.mimeType, 'title': entry.bestTitle, - 'subtitle': date != null ? formatDateTime(date, locale) : null, + 'subtitle': date != null ? formatDateTime(date, locale, use24hour) : null, 'iconUri': entry.uri, }; })); diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index 7aa90ad98..cde075fdc 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -44,7 +44,9 @@ class PlatformMetadataEditService implements MetadataEditService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return {}; } @@ -58,7 +60,9 @@ class PlatformMetadataEditService implements MetadataEditService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return {}; } @@ -74,7 +78,9 @@ class PlatformMetadataEditService implements MetadataEditService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return {}; } @@ -88,7 +94,9 @@ class PlatformMetadataEditService implements MetadataEditService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return {}; } diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 7c487b978..942c7aa5b 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -72,7 +72,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { result['contentId'] = entry.contentId; return CatalogMetadata.fromMap(result); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return null; } @@ -98,7 +100,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { }) as Map; return OverlayMetadata.fromMap(result); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return null; } @@ -140,7 +144,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { }) as Map; return PanoramaInfo.fromMap(result); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return null; } @@ -173,7 +179,9 @@ class PlatformMetadataFetchService implements MetadataFetchService { 'prop': prop, }); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } } return null; } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 3f9f758fe..ecba948fa 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -5,12 +5,12 @@ import 'package:provider/provider.dart'; class Durations { // Flutter animations (with margin) - static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` + static const popupMenuAnimation = Duration(milliseconds: 300 + 20); // ref `_kMenuDuration` used in `_PopupMenuRoute` // page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation` - static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin` - static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` - static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState` - static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` + static const pageTransitionAnimation = Duration(milliseconds: 300 + 20); // ref `transitionDuration` used in `MaterialRouteTransitionMixin` + static const dialogTransitionAnimation = Duration(milliseconds: 150 + 20); // ref `transitionDuration` used in `DialogRoute` + static const drawerTransitionAnimation = Duration(milliseconds: 246 + 20); // ref `_kBaseSettleDuration` used in `DrawerControllerState` + static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 20); // ref `_kToggleDuration` used in `ToggleableStateMixin` // common animations static const sweeperOpacityAnimation = Duration(milliseconds: 150); diff --git a/lib/theme/format.dart b/lib/theme/format.dart index 48de7d153..e0ce13501 100644 --- a/lib/theme/format.dart +++ b/lib/theme/format.dart @@ -2,9 +2,9 @@ import 'package:intl/intl.dart'; String formatDay(DateTime date, String locale) => DateFormat.yMMMd(locale).format(date); -String formatTime(DateTime date, String locale) => DateFormat.Hm(locale).format(date); +String formatTime(DateTime date, String locale, bool use24hour) => (use24hour ? DateFormat.Hm(locale) : DateFormat.jm(locale)).format(date); -String formatDateTime(DateTime date, String locale) => '${formatDay(date, locale)} • ${formatTime(date, locale)}'; +String formatDateTime(DateTime date, String locale, bool use24hour) => '${formatDay(date, locale)} • ${formatTime(date, locale, use24hour)}'; String formatFriendlyDuration(Duration d) { final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0'); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 7f3eb7349..421714a50 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -46,6 +46,8 @@ class AIcons { static const IconData flip = Icons.flip_outlined; static const IconData favourite = Icons.favorite_border; static const IconData favouriteActive = Icons.favorite; + static const IconData filter = MdiIcons.filterOutline; + static const IconData filterOff = MdiIcons.filterOffOutline; static const IconData geoBounds = Icons.public_outlined; static const IconData goUp = Icons.arrow_upward_outlined; static const IconData group = Icons.group_work_outlined; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 88e80e926..a11e37d9b 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -39,12 +39,19 @@ class AndroidFileUtils { Future initAppNames() async { if (_packages.isEmpty) { + debugPrint('Access installed app inventory'); _packages = await androidAppService.getPackages(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); areAppNamesReadyNotifier.value = true; } } + Future resetAppNames() async { + _packages.clear(); + _potentialAppDirs.clear(); + areAppNamesReadyNotifier.value = false; + } + bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO')); bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots'); @@ -181,9 +188,9 @@ class VolumeRelativeDirectory extends Equatable { } Map toMap() => { - 'volumePath': volumePath, - 'relativeDir': relativeDir, - }; + 'volumePath': volumePath, + 'relativeDir': relativeDir, + }; // prefer static method over a null returning factory constructor static VolumeRelativeDirectory? fromPath(String dirPath) { diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 4ef6ce2d9..1a7bc6d45 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aves/app_flavor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; import 'package:latlong2/latlong.dart'; @@ -86,7 +87,7 @@ class Constants { ), ]; - static const List flutterPlugins = [ + static const List _flutterPluginsCommon = [ Dependency( name: 'Connectivity Plus', license: 'BSD 3-Clause', @@ -99,11 +100,6 @@ class Constants { licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus', ), - Dependency( - name: 'FlutterFire (Core, Crashlytics)', - license: 'BSD 3-Clause', - sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', - ), Dependency( name: 'fijkplayer (Aves fork)', license: 'MIT', @@ -160,6 +156,19 @@ class Constants { ), ]; + static const List _flutterPluginsPlayOnly = [ + Dependency( + name: 'FlutterFire (Core, Crashlytics)', + license: 'BSD 3-Clause', + sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', + ), + ]; + + static List flutterPlugins(AppFlavor flavor) => [ + ..._flutterPluginsCommon, + if (flavor == AppFlavor.play) ..._flutterPluginsPlayOnly, + ]; + static const List flutterPackages = [ Dependency( name: 'Charts', diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 64d8ed44d..7316abf5f 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -5,7 +5,7 @@ import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; class GeoUtils { - static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { + static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { List _split(final double value) { // NumberFormat is necessary to create digit after comma if the value // has no decimal point (only necessary for browser) @@ -32,16 +32,6 @@ class GeoUtils { return '$deg° $minText′ $secText″'; } - // returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E'] - static List toDMS(LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) { - final lat = latLng.latitude; - final lng = latLng.longitude; - return [ - '${_decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals)} ${lat < 0 ? 'S' : 'N'}', - '${_decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals)} ${lng < 0 ? 'W' : 'E'}', - ]; - } - static LatLng getLatLngCenter(List points) { double x = 0; double y = 0; diff --git a/lib/utils/time_utils.dart b/lib/utils/time_utils.dart index 505387930..cf36f57c6 100644 --- a/lib/utils/time_utils.dart +++ b/lib/utils/time_utils.dart @@ -13,3 +13,58 @@ extension ExtraDateTime on DateTime { bool get isThisYear => isAtSameYearAs(DateTime.now()); } + +final _unixStampMillisPattern = RegExp(r'\d{13}'); +final _unixStampSecPattern = RegExp(r'\d{10}'); +final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?'); + +DateTime? parseUnknownDateFormat(String s) { + var match = _unixStampMillisPattern.firstMatch(s); + if (match != null) { + final stampString = match.group(0); + if (stampString != null) { + final stampMillis = int.tryParse(stampString); + if (stampMillis != null) { + return DateTime.fromMillisecondsSinceEpoch(stampMillis, isUtc: false); + } + } + } + + match = _unixStampSecPattern.firstMatch(s); + if (match != null) { + final stampString = match.group(0); + if (stampString != null) { + final stampMillis = int.tryParse(stampString); + if (stampMillis != null) { + return DateTime.fromMillisecondsSinceEpoch(stampMillis * 1000, isUtc: false); + } + } + } + + match = _plainPattern.firstMatch(s); + if (match != null) { + final dateString = match.group(1); + final timeString = match.group(3); + final millisString = match.group(5); + + if (dateString != null) { + final year = int.tryParse(dateString.substring(0, 4)); + final month = int.tryParse(dateString.substring(4, 6)); + final day = int.tryParse(dateString.substring(6, 8)); + + if (year != null && month != null && day != null) { + var hour = 0, minute = 0, second = 0, millis = 0; + if (timeString != null) { + hour = int.tryParse(timeString.substring(0, 2)) ?? 0; + minute = int.tryParse(timeString.substring(2, 4)) ?? 0; + second = int.tryParse(timeString.substring(4, 6)) ?? 0; + + if (millisString != null) { + millis = int.tryParse(millisString) ?? 0; + } + } + return DateTime(year, month, day, hour, minute, second, millis); + } + } + } +} diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart index 77f8d0b5f..cf632b400 100644 --- a/lib/widgets/about/app_ref.dart +++ b/lib/widgets/about/app_ref.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/about/policy_page.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; @@ -66,16 +67,18 @@ class _AppReferenceState extends State { } Widget _buildLinks() { + final l10n = context.l10n; return Wrap( - crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, spacing: 16, + crossAxisAlignment: WrapCrossAlignment.center, children: [ LinkChip( leading: const Icon( AIcons.github, size: 24, ), - text: context.l10n.aboutLinkSources, + text: l10n.aboutLinkSources, url: Constants.avesGithub, ), LinkChip( @@ -83,10 +86,28 @@ class _AppReferenceState extends State { AIcons.legal, size: 22, ), - text: context.l10n.aboutLinkLicense, + text: l10n.aboutLinkLicense, url: '${Constants.avesGithub}/blob/main/LICENSE', ), + LinkChip( + leading: const Icon( + AIcons.privacy, + size: 22, + ), + text: l10n.aboutLinkPolicy, + onTap: _goToPolicyPage, + ), ], ); } + + void _goToPolicyPage() { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: PolicyPage.routeName), + builder: (context) => const PolicyPage(), + ), + ); + } } diff --git a/lib/widgets/about/bug_report.dart b/lib/widgets/about/bug_report.dart index 094fd6de4..db9195c68 100644 --- a/lib/widgets/about/bug_report.dart +++ b/lib/widgets/about/bug_report.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:aves/app_flavor.dart'; import 'package:aves/flutter_version.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; @@ -33,7 +34,7 @@ class _BugReportState extends State with FeedbackMixin { @override void initState() { super.initState(); - _infoLoader = _getInfo(); + _infoLoader = _getInfo(context); } @override @@ -123,14 +124,16 @@ class _BugReportState extends State with FeedbackMixin { ); } - Future _getInfo() async { + Future _getInfo(BuildContext context) async { final packageInfo = await PackageInfo.fromPlatform(); final androidInfo = await DeviceInfoPlugin().androidInfo; final hasPlayServices = await availability.hasPlayServices; + final flavor = context.read().toString().split('.')[1]; return [ - 'Aves version: ${packageInfo.version} (Build ${packageInfo.buildNumber})', + 'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})', 'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})', 'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})', + 'Android build: ${androidInfo.display}', 'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}', ].join('\n'); diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index ab8b366e7..3bc167ca4 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -8,6 +8,10 @@ import 'package:flutter/material.dart'; class AboutCredits extends StatelessWidget { const AboutCredits({Key? key}) : super(key: key); + static const translations = [ + 'Русский: D3ZOXY', + ]; + @override Widget build(BuildContext context) { return Padding( @@ -39,6 +43,14 @@ class AboutCredits extends StatelessWidget { ), ), const SizedBox(height: 16), + Text(context.l10n.aboutCreditsTranslators), + ...translations.map( + (line) => Padding( + padding: const EdgeInsetsDirectional.only(start: 8, top: 8), + child: Text(line), + ), + ), + const SizedBox(height: 16), ], ), ); diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 86c5b43aa..3de3ae949 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -1,3 +1,4 @@ +import 'package:aves/app_flavor.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; @@ -6,6 +7,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class Licenses extends StatefulWidget { const Licenses({Key? key}) : super(key: key); @@ -22,7 +24,7 @@ class _LicensesState extends State { void initState() { super.initState(); _platform = List.from(Constants.androidDependencies); - _flutterPlugins = List.from(Constants.flutterPlugins); + _flutterPlugins = List.from(Constants.flutterPlugins(context.read())); _flutterPackages = List.from(Constants.flutterPackages); _dartPackages = List.from(Constants.dartPackages); _sortPackages(); diff --git a/lib/widgets/about/policy_page.dart b/lib/widgets/about/policy_page.dart new file mode 100644 index 000000000..fbfa3adcc --- /dev/null +++ b/lib/widgets/about/policy_page.dart @@ -0,0 +1,49 @@ +import 'package:aves/widgets/common/basic/markdown_container.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class PolicyPage extends StatefulWidget { + static const routeName = '/about/policy'; + + const PolicyPage({ + Key? key, + }) : super(key: key); + + @override + _PolicyPageState createState() => _PolicyPageState(); +} + +class _PolicyPageState extends State { + late Future _termsLoader; + + @override + void initState() { + super.initState(); + _termsLoader = rootBundle.loadString('assets/terms.md'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.policyPageTitle), + ), + body: SafeArea( + child: Center( + child: FutureBuilder( + future: _termsLoader, + builder: (context, snapshot) { + if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox(); + final terms = snapshot.data!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: MarkdownContainer(data: terms), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index bd774b3c3..2518042d3 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aves/app_flavor.dart'; import 'package:aves/app_mode.dart'; import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/screen_on.dart'; @@ -11,6 +12,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/themes.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; @@ -29,7 +31,12 @@ import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class AvesApp extends StatefulWidget { - const AvesApp({Key? key}) : super(key: key); + final AppFlavor flavor; + + const AvesApp({ + Key? key, + required this.flavor, + }) : super(key: key); @override _AvesAppState createState() => _AvesAppState(); @@ -68,59 +75,62 @@ class _AvesAppState extends State { Widget build(BuildContext context) { // place the settings provider above `MaterialApp` // so it can be used during navigation transitions - return ChangeNotifierProvider.value( - value: settings, - child: ListenableProvider>.value( - value: appModeNotifier, - child: Provider.value( - value: _mediaStoreSource, - child: DurationsProvider( - child: HighlightInfoProvider( - child: OverlaySupport( - child: FutureBuilder( - future: _appSetup, - builder: (context, snapshot) { - final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done; - final home = initialized - ? getFirstPage() - : Scaffold( - body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), + return Provider.value( + value: widget.flavor, + child: ChangeNotifierProvider.value( + value: settings, + child: ListenableProvider>.value( + value: appModeNotifier, + child: Provider.value( + value: _mediaStoreSource, + child: DurationsProvider( + child: HighlightInfoProvider( + child: OverlaySupport( + child: FutureBuilder( + future: _appSetup, + builder: (context, snapshot) { + final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done; + final home = initialized + ? getFirstPage() + : Scaffold( + body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(), + ); + return Selector>( + selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true), + builder: (context, s, child) { + final settingsLocale = s.item1; + final areAnimationsEnabled = s.item2; + return MaterialApp( + navigatorKey: _navigatorKey, + home: home, + navigatorObservers: _navigatorObservers, + builder: (context, child) { + if (!areAnimationsEnabled) { + child = Theme( + data: Theme.of(context).copyWith( + // strip page transitions used by `MaterialPageRoute` + pageTransitionsTheme: DirectPageTransitionsTheme(), + ), + child: child!, + ); + } + return child!; + }, + onGenerateTitle: (context) => context.l10n.appName, + darkTheme: Themes.darkTheme, + themeMode: ThemeMode.dark, + locale: settingsLocale, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + // checkerboardRasterCacheImages: true, + // checkerboardOffscreenLayers: true, ); - return Selector>( - selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true), - builder: (context, s, child) { - final settingsLocale = s.item1; - final areAnimationsEnabled = s.item2; - return MaterialApp( - navigatorKey: _navigatorKey, - home: home, - navigatorObservers: _navigatorObservers, - builder: (context, child) { - if (!areAnimationsEnabled) { - child = Theme( - data: Theme.of(context).copyWith( - // strip page transitions used by `MaterialPageRoute` - pageTransitionsTheme: DirectPageTransitionsTheme(), - ), - child: child!, - ); - } - return child!; - }, - onGenerateTitle: (context) => context.l10n.appName, - darkTheme: Themes.darkTheme, - themeMode: ThemeMode.dark, - locale: settingsLocale, - localizationsDelegates: const [ - ...AppLocalizations.localizationsDelegates, - ], - supportedLocales: AppLocalizations.supportedLocales, - // checkerboardRasterCacheImages: true, - // checkerboardOffscreenLayers: true, - ); - }, - ); - }, + }, + ); + }, + ), ), ), ), @@ -159,12 +169,23 @@ class _AvesAppState extends State { ); settings.keepScreenOn.apply(); + // installed app access + settings.updateStream.where((key) => key == Settings.isInstalledAppAccessAllowedKey).listen( + (_) { + if (settings.isInstalledAppAccessAllowed) { + androidFileUtils.initAppNames(); + } else { + androidFileUtils.resetAppNames(); + } + }, + ); + // error reporting await reportService.init(); - settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen( - (_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled), + settings.updateStream.where((key) => key == Settings.isErrorReportingAllowedKey).listen( + (_) => reportService.setCollectionEnabled(settings.isErrorReportingAllowed), ); - await reportService.setCollectionEnabled(settings.isErrorReportingEnabled); + await reportService.setCollectionEnabled(settings.isErrorReportingAllowed); FlutterError.onError = reportService.recordFlutterError; final now = DateTime.now(); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 52699fbd2..5135feec9 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/query.dart'; +import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -11,15 +12,15 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; +import 'package:aves/widgets/collection/query_bar.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -42,20 +43,27 @@ class CollectionAppBar extends StatefulWidget { } class _CollectionAppBarState extends State with SingleTickerProviderStateMixin { + final List _subscriptions = []; final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); late AnimationController _browseToSelectAnimation; late Future _canAddShortcutsLoader; final ValueNotifier _isSelectingNotifier = ValueNotifier(false); + final FocusNode _queryBarFocusNode = FocusNode(); + late final Listenable _queryFocusRequestNotifier; CollectionLens get collection => widget.collection; CollectionSource get source => collection.source; - bool get hasFilters => collection.filters.isNotEmpty; + bool get showFilterBar => collection.filters.any((v) => !(v is QueryFilter && v.live)); @override void initState() { super.initState(); + final query = context.read(); + _subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight())); + _queryFocusRequestNotifier = query.focusRequestNotifier; + _queryFocusRequestNotifier.addListener(_onQueryFocusRequest); _browseToSelectAnimation = AnimationController( duration: context.read().iconAnimation, vsync: this, @@ -76,8 +84,12 @@ class _CollectionAppBarState extends State with SingleTickerPr @override void dispose() { _unregisterWidget(widget); + _queryFocusRequestNotifier.removeListener(_onQueryFocusRequest); _isSelectingNotifier.removeListener(_onActivityChange); _browseToSelectAnimation.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); super.dispose(); } @@ -92,27 +104,55 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - return Selector, bool>( - selector: (context, selection) => selection.isSelecting, - builder: (context, isSelecting, child) { - _isSelectingNotifier.value = isSelecting; - return AnimatedBuilder( - animation: collection.filterChangeNotifier, - builder: (context, child) { - final removableFilters = appMode != AppMode.pickInternal; - return SliverAppBar( - leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, - title: _buildAppBarTitle(isSelecting), - actions: _buildActions(isSelecting), - bottom: hasFilters - ? FilterBar( - filters: collection.filters, - removable: removableFilters, - onTap: removableFilters ? collection.removeFilter : null, - ) - : null, - titleSpacing: 0, - floating: true, + return FutureBuilder( + future: _canAddShortcutsLoader, + builder: (context, snapshot) { + final canAddShortcuts = snapshot.data ?? false; + return Selector, Tuple2>( + selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), + builder: (context, s, child) { + final isSelecting = s.item1; + final selectedItemCount = s.item2; + _isSelectingNotifier.value = isSelecting; + return AnimatedBuilder( + animation: collection.filterChangeNotifier, + builder: (context, child) { + final removableFilters = appMode != AppMode.pickInternal; + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, + title: _buildAppBarTitle(isSelecting), + actions: _buildActions( + isSelecting: isSelecting, + selectedItemCount: selectedItemCount, + supportShortcuts: canAddShortcuts, + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(appBarBottomHeight), + child: Column( + children: [ + if (showFilterBar) + FilterBar( + filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ), + if (queryEnabled) + EntryQueryBar( + queryNotifier: context.select>((query) => query.queryNotifier), + focusNode: _queryBarFocusNode, + ) + ], + ), + ), + titleSpacing: 0, + floating: true, + ); + }, + ); + }, ); }, ); @@ -120,6 +160,11 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } + double get appBarBottomHeight { + final hasQuery = context.read().enabled; + return (showFilterBar ? FilterBar.preferredHeight : .0) + (hasQuery ? EntryQueryBar.preferredHeight : .0); + } + Widget _buildAppBarLeading(bool isSelecting) { VoidCallback? onPressed; String? tooltip; @@ -143,14 +188,16 @@ class _CollectionAppBarState extends State with SingleTickerPr } Widget? _buildAppBarTitle(bool isSelecting) { + final l10n = context.l10n; + if (isSelecting) { return Selector, int>( selector: (context, selection) => selection.selectedItems.length, - builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)), + builder: (context, count, child) => Text(l10n.collectionSelectionPageTitle(count)), ); } else { final appMode = context.watch>().value; - Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); + Widget title = Text(appMode.isPicking ? l10n.collectionPickPageTitle : l10n.collectionPageTitle); if (appMode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, @@ -164,94 +211,171 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - List _buildActions(bool isSelecting) { + List _buildActions({ + required bool isSelecting, + required int selectedItemCount, + required bool supportShortcuts, + }) { final appMode = context.watch>().value; - final selectionQuickActions = settings.collectionSelectionQuickActions; - return [ - if (!isSelecting && appMode.canSearch) - CollectionSearchButton( - source: source, - parentCollection: collection, - ), - if (isSelecting) - ...selectionQuickActions.map((action) => Selector, bool>( - selector: (context, selection) => selection.selectedItems.isEmpty, - builder: (context, isEmpty, child) => IconButton( - icon: action.getIcon(), - onPressed: isEmpty ? null : () => _onCollectionActionSelected(action), - tooltip: action.getText(context), - ), - )), - FutureBuilder( - future: _canAddShortcutsLoader, - builder: (context, snapshot) { - final canAddShortcuts = snapshot.data ?? false; - return MenuIconTheme( - child: PopupMenuButton( - // key is expected by test driver - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - final groupable = collection.sortFactor == EntrySortFactor.date; - final selection = context.read>(); - final isSelecting = selection.isSelecting; - final selectedItems = selection.selectedItems; - final hasSelection = selectedItems.isNotEmpty; - final hasItems = !collection.isEmpty; - final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); + bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + supportShortcuts: supportShortcuts, + sortFactor: collection.sortFactor, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + ); + bool canApply(EntrySetAction action) => _actionDelegate.canApply( + action, + isSelecting: isSelecting, + itemCount: collection.entryCount, + selectedItemCount: selectedItemCount, + ); + final canApplyEditActions = selectedItemCount > 0; - return [ - _toMenuItem(EntrySetAction.sort), - if (groupable) _toMenuItem(EntrySetAction.group), - if (appMode == AppMode.main) ...[ - if (!isSelecting) - _toMenuItem( - EntrySetAction.select, - enabled: hasItems, - ), - const PopupMenuDivider(), - if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)), - if (!isSelecting) + final browsingQuickActions = settings.collectionBrowsingQuickActions; + final selectionQuickActions = settings.collectionSelectionQuickActions; + final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( + (action) => _toActionButton(action, enabled: canApply(action)), + ); + + return [ + ...quickActionButtons, + MenuIconTheme( + child: PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + final generalMenuItems = EntrySetActions.general.where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ); + + final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v)); + final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)); + final contextualMenuItems = [ + ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ), + if (isSelecting) + PopupMenuItem( + enabled: canApplyEditActions, + padding: EdgeInsets.zero, + child: PopupMenuItemExpansionPanel( + enabled: canApplyEditActions, + icon: AIcons.edit, + title: context.l10n.collectionActionEdit, + items: [ + _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ - EntrySetAction.map, - EntrySetAction.stats, - ].map((v) => _toMenuItem(v, enabled: otherViewEnabled)), - if (!isSelecting && canAddShortcuts) ...[ - const PopupMenuDivider(), - _toMenuItem(EntrySetAction.addShortcut), + EntrySetAction.editDate, + EntrySetAction.removeMetadata, + ].map((action) => _toMenuItem(action, enabled: canApply(action))), ], - ], - if (isSelecting) ...[ - const PopupMenuDivider(), - _toMenuItem( - EntrySetAction.selectAll, - enabled: selectedItems.length < collection.entryCount, - ), - _toMenuItem( - EntrySetAction.selectNone, - enabled: hasSelection, - ), - ] - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - await _onCollectionActionSelected(action); - }, - ), - ); - }, + ), + ), + ]; + + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ + const PopupMenuDivider(), + ...contextualMenuItems, + ], + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + await _onActionSelected(action); + }, + ), ), ]; } - PopupMenuItem _toMenuItem(EntrySetAction action, {bool enabled = true}) { + // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') + Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}'); + + Widget _toActionButton(EntrySetAction action, {required bool enabled}) { + final onPressed = enabled ? () => _onActionSelected(action) : null; + switch (action) { + case EntrySetAction.toggleTitleSearch: + return Selector( + selector: (context, query) => query.enabled, + builder: (context, queryEnabled, child) { + return _TitleSearchToggler( + queryEnabled: queryEnabled, + onPressed: onPressed, + ); + }, + ); + default: + return IconButton( + key: _getActionKey(action), + icon: action.getIcon(), + onPressed: onPressed, + tooltip: action.getText(context), + ); + } + } + + PopupMenuItem _toMenuItem(EntrySetAction action, {required bool enabled}) { + late Widget child; + switch (action) { + case EntrySetAction.toggleTitleSearch: + child = _TitleSearchToggler( + queryEnabled: context.read().enabled, + isMenuItem: true, + ); + break; + default: + child = MenuRow(text: action.getText(context), icon: action.getIcon()); + break; + } return PopupMenuItem( - // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') - key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'), + key: _getActionKey(action), value: action, enabled: enabled, - child: MenuRow(text: action.getText(context), icon: action.getIcon()), + child: child, + ); + } + + PopupMenuItem _buildRotateAndFlipMenuItems( + BuildContext context, { + required bool Function(EntrySetAction action) canApply, + }) { + Widget buildDivider() => const SizedBox( + height: 16, + child: VerticalDivider( + width: 1, + thickness: 1, + ), + ); + + Widget buildItem(EntrySetAction action) => Expanded( + child: PopupMenuItem( + value: action, + enabled: canApply(action), + child: Tooltip( + message: action.getText(context), + child: Center(child: action.getIcon()), + ), + ), + ); + + return PopupMenuItem( + child: Row( + children: [ + buildDivider(), + buildItem(EntrySetAction.rotateCCW), + buildDivider(), + buildItem(EntrySetAction.rotateCW), + buildDivider(), + buildItem(EntrySetAction.flip), + buildDivider(), + ], + ), ); } @@ -264,10 +388,10 @@ class _CollectionAppBarState extends State with SingleTickerPr } void _onFilterChanged() { - widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0); + _updateAppBarHeight(); - if (hasFilters) { - final filters = collection.filters; + final filters = collection.filters; + if (filters.isNotEmpty) { final selection = context.read>(); if (selection.isSelecting) { final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); @@ -276,16 +400,18 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - Future _onCollectionActionSelected(EntrySetAction action) async { + void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); + + void _updateAppBarHeight() => widget.appBarHeightNotifier.value = kToolbarHeight + appBarBottomHeight; + + Future _onActionSelected(EntrySetAction action) async { switch (action) { - case EntrySetAction.share: - case EntrySetAction.delete: - case EntrySetAction.copy: - case EntrySetAction.move: - case EntrySetAction.rescan: - case EntrySetAction.map: - case EntrySetAction.stats: - _actionDelegate.onActionSelected(context, action); + // general + case EntrySetAction.sort: + await _sort(); + break; + case EntrySetAction.group: + await _group(); break; case EntrySetAction.select: context.read>().select(); @@ -296,74 +422,71 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.selectNone: context.read>().clearSelection(); break; + // browsing + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: case EntrySetAction.addShortcut: - unawaited(_showShortcutDialog(context)); - break; - case EntrySetAction.group: - final value = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionSectionFactor, - options: { - EntryGroupFactor.album: context.l10n.collectionGroupAlbum, - EntryGroupFactor.month: context.l10n.collectionGroupMonth, - EntryGroupFactor.day: context.l10n.collectionGroupDay, - EntryGroupFactor.none: context.l10n.collectionGroupNone, - }, - title: context.l10n.collectionGroupTitle, - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSectionFactor = value; - } - break; - case EntrySetAction.sort: - final value = await showDialog( - context: context, - builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionSortFactor, - options: { - EntrySortFactor.date: context.l10n.collectionSortDate, - EntrySortFactor.size: context.l10n.collectionSortSize, - EntrySortFactor.name: context.l10n.collectionSortName, - }, - title: context.l10n.collectionSortTitle, - ), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (value != null) { - settings.collectionSortFactor = value; - } + // browsing or selecting + case EntrySetAction.map: + case EntrySetAction.stats: + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + _actionDelegate.onActionSelected(context, action); break; } } - Future _showShortcutDialog(BuildContext context) async { - final filters = collection.filters; - String? defaultName; - if (filters.isNotEmpty) { - // we compute the default name beforehand - // because some filter labels need localization - final sortedFilters = List.from(filters)..sort(); - defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); - } - final result = await showDialog>( + Future _sort() async { + final value = await showDialog( context: context, - builder: (context) => AddShortcutDialog( - collection: collection, - defaultName: defaultName ?? '', + builder: (context) => AvesSelectionDialog( + initialValue: settings.collectionSortFactor, + options: { + EntrySortFactor.date: context.l10n.collectionSortDate, + EntrySortFactor.size: context.l10n.collectionSortSize, + EntrySortFactor.name: context.l10n.collectionSortName, + }, + title: context.l10n.collectionSortTitle, ), ); - if (result == null) return; + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (value != null) { + settings.collectionSortFactor = value; + } + } - final coverEntry = result.item1; - final name = result.item2; - if (name.isEmpty) return; - - unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); + Future _group() async { + final value = await showDialog( + context: context, + builder: (context) { + final l10n = context.l10n; + return AvesSelectionDialog( + initialValue: settings.collectionSectionFactor, + options: { + EntryGroupFactor.album: l10n.collectionGroupAlbum, + EntryGroupFactor.month: l10n.collectionGroupMonth, + EntryGroupFactor.day: l10n.collectionGroupDay, + EntryGroupFactor.none: l10n.collectionGroupNone, + }, + title: l10n.collectionGroupTitle, + ); + }, + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + if (value != null) { + settings.collectionSectionFactor = value; + } } void _goToSearch() { @@ -378,3 +501,30 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } } + +class _TitleSearchToggler extends StatelessWidget { + final bool queryEnabled, isMenuItem; + final VoidCallback? onPressed; + + const _TitleSearchToggler({ + required this.queryEnabled, + this.isMenuItem = false, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final icon = Icon(queryEnabled ? AIcons.filterOff : AIcons.filter); + final text = queryEnabled ? context.l10n.collectionActionHideTitleSearch : context.l10n.collectionActionShowTitleSearch; + return isMenuItem + ? MenuRow( + text: text, + icon: icon, + ) + : IconButton( + icon: icon, + onPressed: onPressed, + tooltip: text, + ); + } +} diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 9cd50c7f6..20d8607e2 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -5,6 +5,7 @@ import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:flutter/foundation.dart'; @@ -39,25 +40,27 @@ class _CollectionPageState extends State { return MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( - child: Builder( - builder: (context) => WillPopScope( - onWillPop: () { - final selection = context.read>(); - if (selection.isSelecting) { - selection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), + child: QueryProvider( + child: Builder( + builder: (context) => WillPopScope( + onWillPop: () { + final selection = context.read>(); + if (selection.isSelecting) { + selection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + ), ), ), ), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index a46b63497..4f9acfb95 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -1,59 +1,188 @@ import 'dart:async'; import 'dart:io'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/map/map_page.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { + bool isVisible( + EntrySetAction action, { + required AppMode appMode, + required bool isSelecting, + required bool supportShortcuts, + required EntrySortFactor sortFactor, + required int itemCount, + required int selectedItemCount, + }) { + switch (action) { + // general + case EntrySetAction.sort: + return true; + case EntrySetAction.group: + return sortFactor == EntrySortFactor.date; + case EntrySetAction.select: + return appMode.canSelect && !isSelecting; + case EntrySetAction.selectAll: + return isSelecting && selectedItemCount < itemCount; + case EntrySetAction.selectNone: + return isSelecting && selectedItemCount == itemCount; + // browsing + case EntrySetAction.searchCollection: + return appMode.canSearch && !isSelecting; + case EntrySetAction.toggleTitleSearch: + return !isSelecting; + case EntrySetAction.addShortcut: + return appMode == AppMode.main && !isSelecting && supportShortcuts; + // browsing or selecting + case EntrySetAction.map: + case EntrySetAction.stats: + return appMode == AppMode.main; + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + return appMode == AppMode.main && isSelecting; + } + } + + bool canApply( + EntrySetAction action, { + required bool isSelecting, + required int itemCount, + required int selectedItemCount, + }) { + final hasItems = itemCount > 0; + final hasSelection = selectedItemCount > 0; + + switch (action) { + case EntrySetAction.sort: + case EntrySetAction.group: + return true; + case EntrySetAction.select: + return hasItems; + case EntrySetAction.selectAll: + return selectedItemCount < itemCount; + case EntrySetAction.selectNone: + return hasSelection; + case EntrySetAction.searchCollection: + case EntrySetAction.toggleTitleSearch: + case EntrySetAction.addShortcut: + return true; + case EntrySetAction.map: + case EntrySetAction.stats: + return (!isSelecting && hasItems) || (isSelecting && hasSelection); + // selecting + case EntrySetAction.share: + case EntrySetAction.delete: + case EntrySetAction.copy: + case EntrySetAction.move: + case EntrySetAction.rescan: + case EntrySetAction.rotateCCW: + case EntrySetAction.rotateCW: + case EntrySetAction.flip: + case EntrySetAction.editDate: + case EntrySetAction.removeMetadata: + return hasSelection; + } + } -class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { void onActionSelected(BuildContext context, EntrySetAction action) { switch (action) { - case EntrySetAction.share: - _share(context); + // general + case EntrySetAction.sort: + case EntrySetAction.group: + case EntrySetAction.select: + case EntrySetAction.selectAll: + case EntrySetAction.selectNone: break; - case EntrySetAction.delete: - _showDeleteDialog(context); + // browsing + case EntrySetAction.searchCollection: + _goToSearch(context); break; - case EntrySetAction.copy: - _moveSelection(context, moveType: MoveType.copy); + case EntrySetAction.toggleTitleSearch: + context.read().toggle(); break; - case EntrySetAction.move: - _moveSelection(context, moveType: MoveType.move); - break; - case EntrySetAction.rescan: - _rescan(context); + case EntrySetAction.addShortcut: + _addShortcut(context); break; + // browsing or selecting case EntrySetAction.map: _goToMap(context); break; case EntrySetAction.stats: _goToStats(context); break; - default: + // selecting + case EntrySetAction.share: + _share(context); + break; + case EntrySetAction.delete: + _delete(context); + break; + case EntrySetAction.copy: + _move(context, moveType: MoveType.copy); + break; + case EntrySetAction.move: + _move(context, moveType: MoveType.move); + break; + case EntrySetAction.rescan: + _rescan(context); + break; + case EntrySetAction.rotateCCW: + _rotate(context, clockwise: false); + break; + case EntrySetAction.rotateCW: + _rotate(context, clockwise: true); + break; + case EntrySetAction.flip: + _flip(context); + break; + case EntrySetAction.editDate: + _editDate(context); + break; + case EntrySetAction.removeMetadata: + _removeMetadata(context); break; } } @@ -81,7 +210,60 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware selection.browse(); } - Future _moveSelection(BuildContext context, {required MoveType moveType}) async { + Future _delete(BuildContext context) async { + final source = context.read(); + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = selectedItems.length; + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.deleteButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return; + + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + + source.pauseMonitoring(); + showOpReport( + context: context, + opStream: mediaFileService.delete(selectedItems), + itemCount: todoCount, + onDone: (processed) async { + final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + await source.removeEntries(deletedUris); + selection.browse(); + source.resumeMonitoring(); + + final deletedCount = deletedUris.length; + if (deletedCount < todoCount) { + final count = todoCount - deletedCount; + showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); + } + + // cleanup + await storageService.deleteEmptyDirectories(selectionDirs); + }, + ); + } + + Future _move(BuildContext context, {required MoveType moveType}) async { final l10n = context.l10n; final source = context.read(); final selection = context.read>(); @@ -104,15 +286,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware // do not directly use selection when moving and post-processing items // as source monitoring may remove obsolete items from the original selection - final todoEntries = selectedItems.toSet(); + final todoItems = selectedItems.toSet(); final copy = moveType == MoveType.copy; - final todoCount = todoEntries.length; + final todoCount = todoItems.length; assert(todoCount > 0); final destinationDirectory = Directory(destinationAlbum); final names = [ - ...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'), + ...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'), // do not guard up front based on directory existence, // as conflicts could be within moved entries scattered across multiple albums if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), @@ -139,7 +321,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware showOpReport( context: context, opStream: mediaFileService.move( - todoEntries, + todoItems, copy: copy, destinationAlbum: destinationAlbum, nameConflictStrategy: nameConflictStrategy, @@ -149,7 +331,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final successOps = processed.where((e) => e.success).toSet(); final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); await source.updateAfterMove( - todoEntries: todoEntries, + todoEntries: todoItems, copy: copy, destinationAlbum: destinationAlbum, movedOps: movedOps, @@ -213,57 +395,128 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } - Future _showDeleteDialog(BuildContext context) async { - final source = context.read(); - final selection = context.read>(); - final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - final todoCount = selectedItems.length; + Future _edit( + BuildContext context, + Selection selection, + Set todoItems, + Future Function(AvesEntry entry) op, + ) async { + final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); + final todoCount = todoItems.length; + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoItems)) return; + + final source = context.read(); + source.pauseMonitoring(); + showOpReport( + context: context, + opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { + final success = await op(entry); + return ImageOpEvent(success: success, uri: entry.uri); + }).asBroadcastStream(), + itemCount: todoCount, + onDone: (processed) async { + final successOps = processed.where((e) => e.success).toSet(); + selection.browse(); + source.resumeMonitoring(); + unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet())); + + final l10n = context.l10n; + final successCount = successOps.length; + if (successCount < todoCount) { + final count = todoCount - successCount; + showFeedback(context, l10n.collectionEditFailureFeedback(count)); + } else { + final count = successCount; + showFeedback(context, l10n.collectionEditSuccessFeedback(count)); + } + }, + ); + } + + Future?> _getEditableItems( + BuildContext context, { + required Set selectedItems, + required bool Function(AvesEntry entry) canEdit, + }) async { + final bySupported = groupBy(selectedItems, canEdit); + final supported = (bySupported[true] ?? []).toSet(); + final unsupported = (bySupported[false] ?? []).toSet(); + + if (unsupported.isEmpty) return supported; + + final unsupportedTypes = unsupported.map((entry) => entry.mimeType).toSet().map(MimeUtils.displayType).toList()..sort(); final confirmed = await showDialog( context: context, builder: (context) { + final l10n = context.l10n; return AvesDialog( context: context, - content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), + title: l10n.unsupportedTypeDialogTitle, + content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.deleteButtonLabel), - ), + if (supported.isNotEmpty) + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.continueButtonLabel), + ), ], ); }, ); - if (confirmed == null || !confirmed) return; + if (confirmed == null || !confirmed) return null; - if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; + return supported; + } - source.pauseMonitoring(); - showOpReport( - context: context, - opStream: mediaFileService.delete(selectedItems), - itemCount: todoCount, - onDone: (processed) async { - final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); - await source.removeEntries(deletedUris); - selection.browse(); - source.resumeMonitoring(); + Future _rotate(BuildContext context, {required bool clockwise}) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); - final deletedCount = deletedUris.length; - if (deletedCount < todoCount) { - final count = todoCount - deletedCount; - showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); - } + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); + if (todoItems == null || todoItems.isEmpty) return; - // cleanup - await storageService.deleteEmptyDirectories(selectionDirs); - }, - ); + await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise, persist: true)); + } + + Future _flip(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip); + if (todoItems == null || todoItems.isEmpty) return; + + await _edit(context, selection, todoItems, (entry) => entry.flip(persist: true)); + } + + Future _editDate(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif); + if (todoItems == null || todoItems.isEmpty) return; + + final modifier = await selectDateModifier(context, todoItems); + if (modifier == null) return; + + await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); + } + + Future _removeMetadata(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRemoveMetadata); + if (todoItems == null || todoItems.isEmpty) return; + + final types = await selectMetadataToRemove(context, todoItems); + if (types == null || types.isEmpty) return; + + await _edit(context, selection, todoItems, (entry) => entry.removeMetadata(types)); } void _goToMap(BuildContext context) { @@ -304,4 +557,45 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ), ); } + + void _goToSearch(BuildContext context) { + final collection = context.read(); + + Navigator.push( + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: collection.source, + parentCollection: collection, + ), + ), + ); + } + + Future _addShortcut(BuildContext context) async { + final collection = context.read(); + final filters = collection.filters; + + String? defaultName; + if (filters.isNotEmpty) { + // we compute the default name beforehand + // because some filter labels need localization + final sortedFilters = List.from(filters)..sort(); + defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); + } + final result = await showDialog>( + context: context, + builder: (context) => AddShortcutDialog( + collection: collection, + defaultName: defaultName ?? '', + ), + ); + if (result == null) return; + + final coverEntry = result.item1; + final name = result.item2; + if (name.isEmpty) return; + + unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); + } } diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 0e5971f61..d73656a2f 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -3,7 +3,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:flutter/material.dart'; -class FilterBar extends StatefulWidget implements PreferredSizeWidget { +class FilterBar extends StatefulWidget { static const double verticalPadding = 16; static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; @@ -19,9 +19,6 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { }) : filters = List.from(filters)..sort(), super(key: key); - @override - final Size preferredSize = const Size.fromHeight(preferredHeight); - @override _FilterBarState createState() => _FilterBarState(); } diff --git a/lib/widgets/collection/query_bar.dart b/lib/widgets/collection/query_bar.dart new file mode 100644 index 000000000..f915b9a49 --- /dev/null +++ b/lib/widgets/collection/query_bar.dart @@ -0,0 +1,76 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/selection.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EntryQueryBar extends StatefulWidget { + final ValueNotifier queryNotifier; + final FocusNode focusNode; + + static const preferredHeight = kToolbarHeight; + + const EntryQueryBar({ + Key? key, + required this.queryNotifier, + required this.focusNode, + }) : super(key: key); + + @override + _EntryQueryBarState createState() => _EntryQueryBarState(); +} + +class _EntryQueryBarState extends State { + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant EntryQueryBar oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + // TODO TLAD focus on text field when enabled (`autofocus` is unusable) + // TODO TLAD lose focus on navigation to viewer? + void _registerWidget(EntryQueryBar widget) { + widget.queryNotifier.addListener(_onQueryChanged); + } + + void _unregisterWidget(EntryQueryBar widget) { + widget.queryNotifier.removeListener(_onQueryChanged); + } + + @override + Widget build(BuildContext context) { + return Container( + height: EntryQueryBar.preferredHeight, + alignment: Alignment.topCenter, + child: Selector, bool>( + selector: (context, selection) => !selection.isSelecting, + builder: (context, editable, child) => QueryBar( + queryNotifier: widget.queryNotifier, + focusNode: widget.focusNode, + hintText: context.l10n.collectionSearchTitlesHintText, + editable: editable, + ), + ), + ); + } + + void _onQueryChanged() { + final query = widget.queryNotifier.value; + context.read().setLiveQuery(query); + } +} diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart new file mode 100644 index 000000000..3212a9827 --- /dev/null +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -0,0 +1,60 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata/date_modifier.dart'; +import 'package:aves/model/metadata/enums.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart'; +import 'package:flutter/material.dart'; + +mixin EntryEditorMixin { + Future selectDateModifier(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final modifier = await showDialog( + context: context, + builder: (context) => EditEntryDateDialog( + entry: entries.first, + ), + ); + return modifier; + } + + Future?> selectMetadataToRemove(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final types = await showDialog>( + context: context, + builder: (context) => RemoveEntryMetadataDialog( + showJpegTypes: entries.any((entry) => entry.mimeType == MimeTypes.jpeg), + ), + ); + if (types == null || types.isEmpty) return null; + + if (entries.any((entry) => entry.isMotionPhoto) && types.contains(MetadataType.xmp)) { + final confirmed = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.applyButtonLabel), + ), + ], + ); + }, + ); + if (confirmed == null || !confirmed) return null; + } + + return types; + } +} diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index 16dbb6537..e3892c578 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -244,7 +244,8 @@ class _DraggableScrollbarState extends State with TickerProv // when the user is not dragging the thumb if (!_isDragInProcess) { if (notification is ScrollUpdateNotification) { - _thumbOffsetNotifier.value = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent).clamp(thumbMinScrollExtent, thumbMaxScrollExtent); + final scrollExtent = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent); + _thumbOffsetNotifier.value = thumbMaxScrollExtent > thumbMinScrollExtent ? scrollExtent.clamp(thumbMinScrollExtent, thumbMaxScrollExtent) : thumbMinScrollExtent; } if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { diff --git a/lib/widgets/common/basic/labeled_checkbox.dart b/lib/widgets/common/basic/labeled_checkbox.dart deleted file mode 100644 index fa8205602..000000000 --- a/lib/widgets/common/basic/labeled_checkbox.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; - -class LabeledCheckbox extends StatefulWidget { - final bool value; - final ValueChanged onChanged; - final String text; - - const LabeledCheckbox({ - Key? key, - required this.value, - required this.onChanged, - required this.text, - }) : super(key: key); - - @override - _LabeledCheckboxState createState() => _LabeledCheckboxState(); -} - -class _LabeledCheckboxState extends State { - late TapGestureRecognizer _tapRecognizer; - - @override - void initState() { - super.initState(); - _tapRecognizer = TapGestureRecognizer()..onTap = () => widget.onChanged(!widget.value); - } - - @override - void dispose() { - _tapRecognizer.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Checkbox( - value: widget.value, - onChanged: widget.onChanged, - ), - ), - TextSpan( - text: widget.text, - recognizer: _tapRecognizer, - ), - ], - ), - ); - } -} diff --git a/lib/widgets/common/basic/link_chip.dart b/lib/widgets/common/basic/link_chip.dart index d1437531f..63929a809 100644 --- a/lib/widgets/common/basic/link_chip.dart +++ b/lib/widgets/common/basic/link_chip.dart @@ -5,9 +5,10 @@ import 'package:url_launcher/url_launcher.dart'; class LinkChip extends StatelessWidget { final Widget? leading; final String text; - final String url; + final String? url; final Color? color; final TextStyle? textStyle; + final VoidCallback? onTap; static const borderRadius = BorderRadius.all(Radius.circular(8)); @@ -15,22 +16,25 @@ class LinkChip extends StatelessWidget { Key? key, this.leading, required this.text, - required this.url, + this.url, this.color, this.textStyle, + this.onTap, }) : super(key: key); @override Widget build(BuildContext context) { + final _url = url; return DefaultTextStyle.merge( style: (textStyle ?? const TextStyle()).copyWith(color: color), child: InkWell( borderRadius: borderRadius, - onTap: () async { - if (await canLaunch(url)) { - await launch(url); - } - }, + onTap: onTap ?? + () async { + if (_url != null && await canLaunch(_url)) { + await launch(_url); + } + }, child: Padding( padding: const EdgeInsets.all(8.0), child: Row( diff --git a/lib/widgets/common/basic/markdown_container.dart b/lib/widgets/common/basic/markdown_container.dart new file mode 100644 index 000000000..521daa510 --- /dev/null +++ b/lib/widgets/common/basic/markdown_container.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MarkdownContainer extends StatelessWidget { + final String data; + + const MarkdownContainer({ + Key? key, + required this.data, + }) : super(key: key); + + static const double maxWidth = 460; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(16)), + color: Colors.white10, + ), + constraints: const BoxConstraints(maxWidth: maxWidth), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: const ScrollbarThemeData( + isAlwaysShown: true, + radius: Radius.circular(16), + crossAxisMargin: 6, + mainAxisMargin: 16, + interactive: true, + ), + ), + child: Scrollbar( + child: Markdown( + data: data, + selectable: true, + onTapLink: (text, href, title) async { + if (href != null && await canLaunch(href)) { + await launch(href); + } + }, + shrinkWrap: true, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart index 3fe9f5583..ad96aa9ef 100644 --- a/lib/widgets/common/basic/menu.dart +++ b/lib/widgets/common/basic/menu.dart @@ -1,4 +1,6 @@ +import 'package:aves/theme/durations.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class MenuRow extends StatelessWidget { final String text; @@ -45,3 +47,75 @@ class MenuIconTheme extends StatelessWidget { ); } } + +class PopupMenuItemExpansionPanel extends StatefulWidget { + final bool enabled; + final IconData icon; + final String title; + final List> items; + + const PopupMenuItemExpansionPanel({ + Key? key, + this.enabled = true, + required this.icon, + required this.title, + required this.items, + }) : super(key: key); + + @override + _PopupMenuItemExpansionPanelState createState() => _PopupMenuItemExpansionPanelState(); +} + +class _PopupMenuItemExpansionPanelState extends State> { + bool _isExpanded = false; + + // ref `_kMenuHorizontalPadding` used in `PopupMenuItem` + static const double _horizontalPadding = 16; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + var style = PopupMenuTheme.of(context).textStyle ?? theme.textTheme.subtitle1!; + if (!widget.enabled) { + style = style.copyWith(color: theme.disabledColor); + } + final animationDuration = context.select((v) => v.expansionTileAnimation); + + Widget child = ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _isExpanded = !isExpanded); + }, + animationDuration: animationDuration, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => DefaultTextStyle( + style: style, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding), + child: MenuRow( + text: widget.title, + icon: Icon(widget.icon), + ), + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PopupMenuDivider(height: 0), + ...widget.items, + const PopupMenuDivider(height: 0), + ], + ), + isExpanded: _isExpanded, + canTapOnHeader: true, + ), + ], + ); + if (!widget.enabled) { + child = IgnorePointer(child: child); + } + return child; + } +} diff --git a/lib/widgets/common/basic/query_bar.dart b/lib/widgets/common/basic/query_bar.dart index 3100c1a5c..2542ab544 100644 --- a/lib/widgets/common/basic/query_bar.dart +++ b/lib/widgets/common/basic/query_bar.dart @@ -7,11 +7,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class QueryBar extends StatefulWidget { - final ValueNotifier filterNotifier; + final ValueNotifier queryNotifier; + final FocusNode? focusNode; + final IconData? icon; + final String? hintText; + final bool editable; const QueryBar({ Key? key, - required this.filterNotifier, + required this.queryNotifier, + this.focusNode, + this.icon, + this.hintText, + this.editable = true, }) : super(key: key); @override @@ -22,22 +30,24 @@ class _QueryBarState extends State { final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); late TextEditingController _controller; - ValueNotifier get filterNotifier => widget.filterNotifier; + ValueNotifier get queryNotifier => widget.queryNotifier; @override void initState() { super.initState(); - _controller = TextEditingController(text: filterNotifier.value); + _controller = TextEditingController(text: queryNotifier.value); } @override Widget build(BuildContext context) { final clearButton = IconButton( icon: const Icon(AIcons.clear), - onPressed: () { - _controller.clear(); - filterNotifier.value = ''; - }, + onPressed: widget.editable + ? () { + _controller.clear(); + queryNotifier.value = ''; + } + : null, tooltip: context.l10n.clearTooltip, ); @@ -47,16 +57,18 @@ class _QueryBarState extends State { Expanded( child: TextField( controller: _controller, + focusNode: widget.focusNode ?? FocusNode(), decoration: InputDecoration( - icon: const Padding( - padding: EdgeInsetsDirectional.only(start: 16), - child: Icon(AIcons.search), + icon: Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: Icon(widget.icon ?? AIcons.filter), ), - hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintText: widget.hintText ?? MaterialLocalizations.of(context).searchFieldLabel, hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, ), textInputAction: TextInputAction.search, - onChanged: (s) => _debouncer(() => filterNotifier.value = s), + onChanged: (s) => _debouncer(() => queryNotifier.value = s.trim()), + enabled: widget.editable, ), ), ConstrainedBox( @@ -73,7 +85,7 @@ class _QueryBarState extends State { child: child, ), ), - child: value.text.isNotEmpty ? clearButton : const SizedBox.shrink(), + child: value.text.isNotEmpty ? clearButton : const SizedBox(), ), ), ) diff --git a/lib/widgets/common/identity/buttons.dart b/lib/widgets/common/identity/buttons.dart index 6d6b971e9..797026f83 100644 --- a/lib/widgets/common/identity/buttons.dart +++ b/lib/widgets/common/identity/buttons.dart @@ -14,9 +14,16 @@ class AvesOutlinedButton extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final style = ButtonStyle( - side: MaterialStateProperty.all(BorderSide(color: Theme.of(context).colorScheme.secondary)), - foregroundColor: MaterialStateProperty.all(Colors.white), + side: MaterialStateProperty.resolveWith((states) { + return BorderSide( + color: states.contains(MaterialState.disabled) ? theme.disabledColor : theme.colorScheme.secondary, + ); + }), + foregroundColor: MaterialStateProperty.resolveWith((states) { + return states.contains(MaterialState.disabled) ? theme.disabledColor : Colors.white; + }), ); return icon != null ? OutlinedButton.icon( diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart index 27513bb9a..5eb76aea2 100644 --- a/lib/widgets/common/magnifier/controller/controller.dart +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -12,6 +12,7 @@ class MagnifierController { final StreamController _scaleBoundariesStreamController = StreamController.broadcast(); final StreamController _scaleStateChangeStreamController = StreamController.broadcast(); + bool _disposed = false; late MagnifierState _currentState, initial, previousState; ScaleBoundaries? _scaleBoundaries; late ScaleStateChange _currentScaleState, previousScaleState; @@ -54,8 +55,8 @@ class MagnifierController { bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut; - /// Closes streams and removes eventual listeners. void dispose() { + _disposed = true; _stateStreamController.close(); _scaleBoundariesStreamController.close(); _scaleStateChangeStreamController.close(); @@ -79,23 +80,25 @@ class MagnifierController { } void setScaleState(ScaleState newValue, ChangeSource source, {Offset? childFocalPoint}) { - if (_currentScaleState.state == newValue) return; + if (_disposed || _currentScaleState.state == newValue) return; previousScaleState = _currentScaleState; _currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint); - _scaleStateChangeStreamController.sink.add(scaleState); + _scaleStateChangeStreamController.add(scaleState); } void _setState(MagnifierState state) { - if (_currentState == state) return; + if (_disposed || _currentState == state) return; + _currentState = state; - _stateStreamController.sink.add(state); + _stateStreamController.add(state); } void setScaleBoundaries(ScaleBoundaries scaleBoundaries) { - if (_scaleBoundaries == scaleBoundaries) return; + if (_disposed || _scaleBoundaries == scaleBoundaries) return; + _scaleBoundaries = scaleBoundaries; - _scaleBoundariesStreamController.sink.add(scaleBoundaries); + _scaleBoundariesStreamController.add(scaleBoundaries); if (!isZooming) { update( @@ -106,9 +109,10 @@ class MagnifierController { } void _setScaleState(ScaleStateChange scaleState) { - if (_currentScaleState == scaleState) return; + if (_disposed || _currentScaleState == scaleState) return; + _currentScaleState = scaleState; - _scaleStateChangeStreamController.sink.add(_currentScaleState); + _scaleStateChangeStreamController.add(_currentScaleState); } double? getScaleForScaleState(ScaleState scaleState) { diff --git a/lib/widgets/common/providers/query_provider.dart b/lib/widgets/common/providers/query_provider.dart new file mode 100644 index 000000000..75a062eec --- /dev/null +++ b/lib/widgets/common/providers/query_provider.dart @@ -0,0 +1,20 @@ +import 'package:aves/model/query.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class QueryProvider extends StatelessWidget { + final Widget child; + + const QueryProvider({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => Query(), + child: child, + ); + } +} diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index 26af6f3d8..3edba69d8 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -94,18 +94,34 @@ class _ThumbnailImageState extends State { _lastException = null; _providers.clear(); + + final highQuality = entry.getThumbnail(extent: extent); + ThumbnailProvider? lowQuality; + if (widget.progressive && !entry.isSvg) { + if (entry.isVideo) { + // previously fetched thumbnail + final cached = entry.bestCachedThumbnail; + final lowQualityExtent = cached.key.extent; + if (lowQualityExtent > 0 && lowQualityExtent != extent) { + lowQuality = cached; + } + } else { + // default platform thumbnail + lowQuality = entry.getThumbnail(); + } + } _providers.addAll([ - if (widget.progressive && !entry.isSvg) + if (lowQuality != null) _ConditionalImageProvider( ScrollAwareImageProvider( context: _scrollAwareContext, - imageProvider: entry.getThumbnail(), + imageProvider: lowQuality, ), ), _ConditionalImageProvider( ScrollAwareImageProvider( context: _scrollAwareContext, - imageProvider: entry.getThumbnail(extent: extent), + imageProvider: highQuality, ), _needSizedProvider, ), @@ -233,7 +249,7 @@ class _ThumbnailImageState extends State { if (animate && widget.heroTag != null) { final background = settings.imageBackground; - final backgroundColor = background.isColor? background.color : null; + final backgroundColor = background.isColor ? background.color : null; image = Hero( tag: widget.heroTag!, flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index fdf0be831..742455f33 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -50,7 +50,7 @@ class _AppDebugPageState extends State { const DebugAndroidEnvironmentSection(), const DebugCacheSection(), const DebugAppDatabaseSection(), - const DebugFirebaseSection(), + const DebugErrorReportingSection(), const DebugSettingsSection(), const DebugStorageSection(), ], diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index c7711f779..12c0c973d 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -23,6 +24,7 @@ class _DebugAppDatabaseSectionState extends State with late Future> _dbAddressLoader; late Future> _dbFavouritesLoader; late Future> _dbCoversLoader; + late Future> _dbVideoPlaybackLoader; @override void initState() { @@ -188,6 +190,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + future: _dbVideoPlaybackLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + + return Row( + children: [ + Expanded( + child: Text('video playback rows: ${snapshot.data!.length}'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => metadataDb.clearVideoPlayback().then((_) => _startDbReport()), + child: const Text('Clear'), + ), + ], + ); + }, + ), ], ), ), @@ -197,12 +220,13 @@ class _DebugAppDatabaseSectionState extends State with void _startDbReport() { _dbFileSizeLoader = metadataDb.dbFileSize(); - _dbEntryLoader = metadataDb.loadEntries(); + _dbEntryLoader = metadataDb.loadAllEntries(); _dbDateLoader = metadataDb.loadDates(); - _dbMetadataLoader = metadataDb.loadMetadataEntries(); - _dbAddressLoader = metadataDb.loadAddresses(); - _dbFavouritesLoader = metadataDb.loadFavourites(); - _dbCoversLoader = metadataDb.loadCovers(); + _dbMetadataLoader = metadataDb.loadAllMetadataEntries(); + _dbAddressLoader = metadataDb.loadAllAddresses(); + _dbFavouritesLoader = metadataDb.loadAllFavourites(); + _dbCoversLoader = metadataDb.loadAllCovers(); + _dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback(); setState(() {}); } diff --git a/lib/widgets/debug/report.dart b/lib/widgets/debug/report.dart index 27227e4c2..b97df4b14 100644 --- a/lib/widgets/debug/report.dart +++ b/lib/widgets/debug/report.dart @@ -2,11 +2,10 @@ import 'package:aves/services/android_debug_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; -class DebugFirebaseSection extends StatelessWidget { - const DebugFirebaseSection({Key? key}) : super(key: key); +class DebugErrorReportingSection extends StatelessWidget { + const DebugErrorReportingSection({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -51,10 +50,7 @@ class DebugFirebaseSection extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( - info: { - 'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}', - 'Crashlytics collection enabled': '${reportService.isCollectionEnabled}', - }, + info: reportService.state, ), ) ], diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index c4a28c2a0..e589f43ab 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class AvesDialog extends AlertDialog { - static const EdgeInsets contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24); + static const double defaultHorizontalContentPadding = 24; static const double controlCaptionPadding = 16; static const double borderWidth = 1.0; @@ -16,6 +16,7 @@ class AvesDialog extends AlertDialog { ScrollController? scrollController, List? scrollableContent, bool hasScrollBar = true, + double horizontalContentPadding = defaultHorizontalContentPadding, Widget? content, required List actions, }) : assert((scrollableContent != null) ^ (content != null)), @@ -34,7 +35,7 @@ class AvesDialog extends AlertDialog { // and overflow feedback ignores the dialog shape, // so we restrict scrolling to the content instead content: _buildContent(context, scrollController, scrollableContent, hasScrollBar, content), - contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(horizontalContentPadding, 20, horizontalContentPadding, 0), actions: actions, actionsPadding: const EdgeInsets.symmetric(horizontal: 8), shape: RoundedRectangleBorder( @@ -115,7 +116,7 @@ class DialogTitle extends StatelessWidget { Widget build(BuildContext context) { return Container( alignment: Alignment.center, - padding: const EdgeInsets.symmetric(vertical: 20), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), decoration: BoxDecoration( border: Border( bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth), @@ -127,6 +128,7 @@ class DialogTitle extends StatelessWidget { fontWeight: FontWeight.normal, fontFeatures: [FontFeature.enable('smcp')], ), + textAlign: TextAlign.center, ), ); } diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/cover_selection_dialog.dart index 3bafd9a88..2dca81942 100644 --- a/lib/widgets/dialogs/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/cover_selection_dialog.dart @@ -123,7 +123,7 @@ class _CoverSelectionDialogState extends State { builder: (context) => ItemPickDialog( collection: CollectionLens( source: context.read(), - filters: [filter], + filters: {filter}, ), ), fullscreenDialog: true, diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart index da9fa5759..2e5965d24 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -44,6 +44,8 @@ class _CreateAlbumDialogState extends State { @override Widget build(BuildContext context) { + const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); + final volumeTiles = []; if (_allVolumes.length > 1) { final byPrimary = groupBy(_allVolumes, (volume) => volume.isPrimary); @@ -52,7 +54,7 @@ class _CreateAlbumDialogState extends State { final otherVolumes = (byPrimary[false] ?? [])..sort(compare); volumeTiles.addAll([ Padding( - padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(top: 20), + padding: contentHorizontalPadding + const EdgeInsets.only(top: 20), child: Text(context.l10n.newAlbumDialogStorageLabel), ), ...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)), @@ -68,7 +70,7 @@ class _CreateAlbumDialogState extends State { scrollableContent: [ ...volumeTiles, Padding( - padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(bottom: 8), + padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8), child: ValueListenableBuilder( valueListenable: _existsNotifier, builder: (context, exists, child) { diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/edit_entry_date_dialog.dart index 064e2181a..0d6056f79 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/edit_entry_date_dialog.dart @@ -5,6 +5,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -44,125 +45,141 @@ class _EditEntryDateDialogState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; - void _updateAction(DateEditAction? action) { - if (action == null) return; - setState(() => _action = action); - } + return MediaQueryDataProvider( + child: Builder( + builder: (context) { + final l10n = context.l10n; + final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); - Widget _tileText(String text) => Text( - text, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); + void _updateAction(DateEditAction? action) { + if (action == null) return; + setState(() => _action = action); + } - final setTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.set, + Widget _tileText(String text) => Text( + text, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + + final setTile = Row( + children: [ + Expanded( + child: RadioListTile( + value: DateEditAction.set, + groupValue: _action, + onChanged: _updateAction, + title: _tileText(l10n.editEntryDateDialogSet), + subtitle: Text(formatDateTime(_dateTime, locale, use24hour)), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: IconButton( + icon: const Icon(AIcons.edit), + onPressed: _action == DateEditAction.set ? _editDate : null, + tooltip: l10n.changeTooltip, + ), + ), + ], + ); + final shiftTile = Row( + children: [ + Expanded( + child: RadioListTile( + value: DateEditAction.shift, + groupValue: _action, + onChanged: _updateAction, + title: _tileText(l10n.editEntryDateDialogShift), + subtitle: Text(_formatShiftDuration()), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: IconButton( + icon: const Icon(AIcons.edit), + onPressed: _action == DateEditAction.shift ? _editShift : null, + tooltip: l10n.changeTooltip, + ), + ), + ], + ); + final extractFromTitleTile = RadioListTile( + value: DateEditAction.extractFromTitle, groupValue: _action, onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogSet), - subtitle: Text(formatDateTime(_dateTime, l10n.localeName)), - ), - ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.set ? _editDate : null, - tooltip: l10n.changeTooltip, - ), - ), - ], - ); - final shiftTile = Row( - children: [ - Expanded( - child: RadioListTile( - value: DateEditAction.shift, + title: _tileText(l10n.editEntryDateDialogExtractFromTitle), + ); + final clearTile = RadioListTile( + value: DateEditAction.clear, groupValue: _action, onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogShift), - subtitle: Text(_formatShiftDuration()), - ), - ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: IconButton( - icon: const Icon(AIcons.edit), - onPressed: _action == DateEditAction.shift ? _editShift : null, - tooltip: l10n.changeTooltip, - ), - ), - ], - ); - final clearTile = RadioListTile( - value: DateEditAction.clear, - groupValue: _action, - onChanged: _updateAction, - title: _tileText(l10n.editEntryDateDialogClear), - ); + title: _tileText(l10n.editEntryDateDialogClear), + ); - final animationDuration = context.select((v) => v.expansionTileAnimation); - final theme = Theme.of(context); - return Theme( - data: theme.copyWith( - textTheme: theme.textTheme.copyWith( - // dense style font for tile subtitles, without modifying title font - bodyText2: const TextStyle(fontSize: 12), - ), - ), - child: AvesDialog( - context: context, - title: l10n.editEntryDateDialogTitle, - scrollableContent: [ - setTile, - shiftTile, - clearTile, - Padding( - padding: const EdgeInsets.only(bottom: 1), - child: ExpansionPanelList( - expansionCallback: (index, isExpanded) { - setState(() => _showOptions = !isExpanded); - }, - animationDuration: animationDuration, - expandedHeaderPadding: EdgeInsets.zero, - elevation: 0, - children: [ - ExpansionPanel( - headerBuilder: (context, isExpanded) => ListTile( - title: Text(l10n.editEntryDateDialogFieldSelection), + final animationDuration = context.select((v) => v.expansionTileAnimation); + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + textTheme: theme.textTheme.copyWith( + // dense style font for tile subtitles, without modifying title font + bodyText2: const TextStyle(fontSize: 12), + ), + ), + child: AvesDialog( + context: context, + title: l10n.editEntryDateDialogTitle, + scrollableContent: [ + setTile, + shiftTile, + extractFromTitleTile, + clearTile, + Padding( + padding: const EdgeInsets.only(bottom: 1), + child: ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() => _showOptions = !isExpanded); + }, + animationDuration: animationDuration, + expandedHeaderPadding: EdgeInsets.zero, + elevation: 0, + children: [ + ExpansionPanel( + headerBuilder: (context, isExpanded) => ListTile( + title: Text(l10n.editEntryDateDialogFieldSelection), + ), + body: Column( + children: DateModifier.allDateFields + .map((field) => SwitchListTile( + value: _fields.contains(field), + onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), + title: Text(_fieldTitle(field)), + )) + .toList(), + ), + isExpanded: _showOptions, + canTapOnHeader: true, + backgroundColor: Theme.of(context).dialogBackgroundColor, + ), + ], ), - body: Column( - children: DateModifier.allDateFields - .map((field) => SwitchListTile( - value: _fields.contains(field), - onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)), - title: Text(_fieldTitle(field)), - )) - .toList(), - ), - isExpanded: _showOptions, - canTapOnHeader: true, - backgroundColor: Theme.of(context).dialogBackgroundColor, + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => _submit(context), + child: Text(l10n.applyButtonLabel), ), ], ), - ), - ], - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => _submit(context), - child: Text(l10n.applyButtonLabel), - ), - ], + ); + }, ), ); } @@ -233,6 +250,7 @@ class _EditEntryDateDialogState extends State { case DateEditAction.shift: modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); break; + case DateEditAction.extractFromTitle: case DateEditAction.clear: modifier = DateModifier(_action, _fields); break; diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart index 0c2c93709..51b2f497d 100644 --- a/lib/widgets/dialogs/item_pick_dialog.dart +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -5,6 +5,7 @@ import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -39,13 +40,15 @@ class _ItemPickDialogState extends State { child: MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( - child: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: ChangeNotifierProvider.value( - value: collection, - child: const CollectionGrid( - settingsRouteKey: CollectionPage.routeName, + child: QueryProvider( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: const CollectionGrid( + settingsRouteKey: CollectionPage.routeName, + ), ), ), ), diff --git a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart index 8ada8046a..2ecc94bef 100644 --- a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/remove_entry_metadata_dialog.dart @@ -1,7 +1,5 @@ -import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/ref/brand_colors.dart'; -import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -14,11 +12,11 @@ import 'package:provider/provider.dart'; import 'aves_dialog.dart'; class RemoveEntryMetadataDialog extends StatefulWidget { - final AvesEntry entry; + final bool showJpegTypes; const RemoveEntryMetadataDialog({ Key? key, - required this.entry, + required this.showJpegTypes, }) : super(key: key); @override @@ -31,14 +29,12 @@ class _RemoveEntryMetadataDialogState extends State { bool _showMore = false; final ValueNotifier _isValidNotifier = ValueNotifier(false); - AvesEntry get entry => widget.entry; - @override void initState() { super.initState(); final byMain = groupBy([ ...MetadataTypes.common, - if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg, + if (widget.showJpegTypes) ...MetadataTypes.jpeg, ], MetadataTypes.main.contains); _mainOptions = (byMain[true] ?? [])..sort(_compareTypeText); _moreOptions = (byMain[false] ?? [])..sort(_compareTypeText); diff --git a/lib/widgets/dialogs/video_speed_dialog.dart b/lib/widgets/dialogs/video_speed_dialog.dart index e1afe9109..d7ecebb7a 100644 --- a/lib/widgets/dialogs/video_speed_dialog.dart +++ b/lib/widgets/dialogs/video_speed_dialog.dart @@ -32,17 +32,20 @@ class _VideoSpeedDialogState extends State { Widget build(BuildContext context) { return AvesDialog( context: context, + horizontalContentPadding: 4, content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const SizedBox(width: 24), - Text(context.l10n.videoSpeedDialogLabel), - const SizedBox(width: 16), - Text('x$_speed'), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Wrap( + children: [ + Text(context.l10n.videoSpeedDialogLabel), + const SizedBox(width: 16), + Text('x$_speed'), + ], + ), ), const SizedBox(height: 16), Slider( diff --git a/lib/widgets/drawer/collection_nav_tile.dart b/lib/widgets/drawer/collection_nav_tile.dart index 7368f6483..f8fa85239 100644 --- a/lib/widgets/drawer/collection_nav_tile.dart +++ b/lib/widgets/drawer/collection_nav_tile.dart @@ -50,7 +50,7 @@ class CollectionNavTile extends StatelessWidget { builder: (context) => CollectionPage( collection: CollectionLens( source: context.read(), - filters: [filter], + filters: {filter}, ), ), ), diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 71d80804f..1a4634d94 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -18,10 +18,8 @@ import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -96,7 +94,7 @@ class AlbumPickAppBar extends StatelessWidget { final AlbumChipSetActionDelegate actionDelegate; final ValueNotifier queryNotifier; - static const preferredHeight = kToolbarHeight + AlbumFilterBar.preferredHeight; + static const preferredHeight = kToolbarHeight + AlbumQueryBar.preferredHeight; const AlbumPickAppBar({ Key? key, @@ -127,8 +125,8 @@ class AlbumPickAppBar extends StatelessWidget { title: Text(title()), source: source, ), - bottom: AlbumFilterBar( - filterNotifier: queryNotifier, + bottom: AlbumQueryBar( + queryNotifier: queryNotifier, ), actions: [ if (moveType != null) @@ -176,14 +174,14 @@ class AlbumPickAppBar extends StatelessWidget { } } -class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { - final ValueNotifier filterNotifier; +class AlbumQueryBar extends StatelessWidget implements PreferredSizeWidget { + final ValueNotifier queryNotifier; static const preferredHeight = kToolbarHeight; - const AlbumFilterBar({ + const AlbumQueryBar({ Key? key, - required this.filterNotifier, + required this.queryNotifier, }) : super(key: key); @override @@ -192,10 +190,10 @@ class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return Container( - height: AlbumFilterBar.preferredHeight, + height: AlbumQueryBar.preferredHeight, alignment: Alignment.topCenter, child: QueryBar( - filterNotifier: filterNotifier, + queryNotifier: queryNotifier, ), ); } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 4e798ed86..e82640bd2 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -42,7 +42,6 @@ class AlbumListPage extends StatelessWidget { source: source, title: context.l10n.albumPageTitle, sortFactor: settings.albumSortFactor, - groupable: true, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, actionDelegate: AlbumChipSetActionDelegate(gridItems), filterSections: groupToSections(context, source, gridItems), diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 334f455b7..606c928a8 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -1,10 +1,12 @@ import 'dart:io'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; +import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -39,29 +41,54 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor; @override - bool isValid(Set filters, ChipSetAction action) { + bool isVisible( + ChipSetAction action, { + required AppMode appMode, + required bool isSelecting, + required int itemCount, + required Set selectedFilters, + }) { switch (action) { + case ChipSetAction.group: + return true; case ChipSetAction.createAlbum: + return appMode == AppMode.main && !isSelecting; case ChipSetAction.delete: case ChipSetAction.rename: - return true; + return appMode == AppMode.main && isSelecting; default: - return super.isValid(filters, action); + return super.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + itemCount: itemCount, + selectedFilters: selectedFilters, + ); } } @override - bool canApply(Set filters, ChipSetAction action) { + bool canApply( + ChipSetAction action, { + required bool isSelecting, + required int itemCount, + required Set selectedFilters, + }) { switch (action) { case ChipSetAction.rename: { - if (filters.length != 1) return false; + if (selectedFilters.length != 1) return false; // do not allow renaming volume root - final dir = VolumeRelativeDirectory.fromPath(filters.first.album); + final dir = VolumeRelativeDirectory.fromPath(selectedFilters.first.album); return dir != null && dir.relativeDir.isNotEmpty; } default: - return super.canApply(filters, action); + return super.canApply( + action, + isSelecting: isSelecting, + itemCount: itemCount, + selectedFilters: selectedFilters, + ); } } @@ -70,18 +97,18 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { switch (action) { // general case ChipSetAction.group: - _showGroupDialog(context); + _group(context); break; case ChipSetAction.createAlbum: _createAlbum(context); break; // single/multiple filters case ChipSetAction.delete: - _showDeleteDialog(context, filters); + _delete(context, filters); break; // single filter case ChipSetAction.rename: - _showRenameDialog(context, filters.first); + _rename(context, filters.first); break; default: break; @@ -89,7 +116,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { super.onActionSelected(context, filters, action); } - Future _showGroupDialog(BuildContext context) async { + void _browse(BuildContext context) => context.read>>().browse(); + + Future _group(BuildContext context) async { final factor = await showDialog( context: context, builder: (context) => AvesSelectionDialog( @@ -129,7 +158,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { } } - Future _showDeleteDialog(BuildContext context, Set filters) async { + Future _delete(BuildContext context, Set filters) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); final source = context.read(); @@ -173,6 +202,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); await source.removeEntries(deletedUris); + _browse(context); source.resumeMonitoring(); final deletedCount = deletedUris.length; @@ -187,7 +217,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { ); } - Future _showRenameDialog(BuildContext context, AlbumFilter filter) async { + Future _rename(BuildContext context, AlbumFilter filter) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); final source = context.read(); @@ -238,6 +268,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { onDone: (processed) async { final movedOps = processed.where((e) => e.success).toSet(); await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps); + _browse(context); source.resumeMonitoring(); final movedCount = movedOps.length; diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 9f18ae074..bb02c7bf1 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -1,3 +1,4 @@ +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; @@ -16,6 +17,7 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; import 'package:aves/widgets/map/map_page.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -30,23 +32,63 @@ abstract class ChipSetActionDelegate with FeedbackMi set sortFactor(ChipSortFactor factor); - bool isValid(Set filters, ChipSetAction action) { - final hasSelection = filters.isNotEmpty; + bool isVisible( + ChipSetAction action, { + required AppMode appMode, + required bool isSelecting, + required int itemCount, + required Set selectedFilters, + }) { + final selectedItemCount = selectedFilters.length; + final hasSelection = selectedFilters.isNotEmpty; switch (action) { + // general + case ChipSetAction.sort: + return true; + case ChipSetAction.group: + return false; + case ChipSetAction.select: + return appMode.canSelect && !isSelecting; + case ChipSetAction.selectAll: + return isSelecting && selectedItemCount < itemCount; + case ChipSetAction.selectNone: + return isSelecting && selectedItemCount == itemCount; + // browsing + case ChipSetAction.search: + return appMode.canSearch && !isSelecting; case ChipSetAction.createAlbum: + return false; + // browsing or selecting + case ChipSetAction.map: + case ChipSetAction.stats: + return appMode == AppMode.main; + // selecting (single/multiple filters) case ChipSetAction.delete: + return false; + case ChipSetAction.hide: + return appMode == AppMode.main; + case ChipSetAction.pin: + return !hasSelection || !settings.pinnedFilters.containsAll(selectedFilters); + case ChipSetAction.unpin: + return hasSelection && settings.pinnedFilters.containsAll(selectedFilters); + // selecting (single filter) case ChipSetAction.rename: return false; - case ChipSetAction.pin: - return !hasSelection || !settings.pinnedFilters.containsAll(filters); - case ChipSetAction.unpin: - return hasSelection && settings.pinnedFilters.containsAll(filters); - default: - return true; + case ChipSetAction.setCover: + return appMode == AppMode.main; } } - bool canApply(Set filters, ChipSetAction action) { + bool canApply( + ChipSetAction action, { + required bool isSelecting, + required int itemCount, + required Set selectedFilters, + }) { + final selectedItemCount = selectedFilters.length; + final hasItems = itemCount > 0; + final hasSelection = selectedItemCount > 0; + switch (action) { // general case ChipSetAction.sort: @@ -54,20 +96,24 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.select: case ChipSetAction.selectAll: case ChipSetAction.selectNone: - case ChipSetAction.map: - case ChipSetAction.stats: + // browsing + case ChipSetAction.search: case ChipSetAction.createAlbum: return true; - // single/multiple filters + // browsing or selecting + case ChipSetAction.map: + case ChipSetAction.stats: + return (!isSelecting && hasItems) || (isSelecting && hasSelection); + // selecting (single/multiple filters) case ChipSetAction.delete: case ChipSetAction.hide: case ChipSetAction.pin: case ChipSetAction.unpin: - return filters.isNotEmpty; - // single filter + return hasSelection; + // selecting (single filter) case ChipSetAction.rename: case ChipSetAction.setCover: - return filters.length == 1; + return selectedItemCount == 1; } } @@ -77,11 +123,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.sort: _showSortDialog(context); break; - case ChipSetAction.map: - _goToMap(context, filters); - break; - case ChipSetAction.stats: - _goToStats(context, filters); + case ChipSetAction.group: break; case ChipSetAction.select: context.read>>().select(); @@ -92,25 +134,44 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.selectNone: context.read>>().clearSelection(); break; - // single/multiple filters - case ChipSetAction.pin: - settings.pinnedFilters = settings.pinnedFilters..addAll(filters); + // browsing + case ChipSetAction.search: + _goToSearch(context); break; - case ChipSetAction.unpin: - settings.pinnedFilters = settings.pinnedFilters..removeAll(filters); + case ChipSetAction.createAlbum: + break; + // browsing or selecting + case ChipSetAction.map: + _goToMap(context, filters); + break; + case ChipSetAction.stats: + _goToStats(context, filters); + break; + // selecting (single/multiple filters) + case ChipSetAction.delete: break; case ChipSetAction.hide: _hide(context, filters); break; - // single filter - case ChipSetAction.setCover: - _showCoverSelectionDialog(context, filters.first); + case ChipSetAction.pin: + settings.pinnedFilters = settings.pinnedFilters..addAll(filters); + _browse(context); break; - default: + case ChipSetAction.unpin: + settings.pinnedFilters = settings.pinnedFilters..removeAll(filters); + _browse(context); + break; + // selecting (single filter) + case ChipSetAction.rename: + break; + case ChipSetAction.setCover: + _setCover(context, filters.first); break; } } + void _browse(BuildContext context) => context.read>>().browse(); + Iterable _selectedEntries(BuildContext context, Set filters) { final source = context.read(); final visibleEntries = source.visibleEntries; @@ -167,6 +228,17 @@ abstract class ChipSetActionDelegate with FeedbackMi ); } + void _goToSearch(BuildContext context) { + Navigator.push( + context, + SearchPageRoute( + delegate: CollectionSearchDelegate( + source: context.read(), + ), + ), + ); + } + Future _hide(BuildContext context, Set filters) async { final confirmed = await showDialog( context: context, @@ -191,9 +263,11 @@ abstract class ChipSetActionDelegate with FeedbackMi final source = context.read(); source.changeFilterVisibility(filters, false); + + _browse(context); } - void _showCoverSelectionDialog(BuildContext context, T filter) async { + void _setCover(BuildContext context, T filter) async { final contentId = covers.coverContentId(filter); final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId); final coverSelection = await showDialog>( @@ -207,5 +281,7 @@ abstract class ChipSetActionDelegate with FeedbackMi final isCustom = coverSelection.item1; await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null); + + _browse(context); } } diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index cc5bbbfc5..c47e56b37 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -11,7 +11,6 @@ import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; -import 'package:aves/widgets/search/search_button.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,15 +20,14 @@ import 'package:provider/provider.dart'; class FilterGridAppBar extends StatefulWidget { final CollectionSource source; final String title; - final ChipSetActionDelegate actionDelegate; - final bool groupable, isEmpty; + final ChipSetActionDelegate actionDelegate; + final bool isEmpty; const FilterGridAppBar({ Key? key, required this.source, required this.title, required this.actionDelegate, - required this.groupable, required this.isEmpty, }) : super(key: key); @@ -45,15 +43,14 @@ class _FilterGridAppBarState extends State widget.actionDelegate; - static const filterSelectionActions = [ + static const browsingQuickActions = [ + ChipSetAction.search, + ]; + static const selectionQuickActions = [ ChipSetAction.setCover, ChipSetAction.pin, ChipSetAction.unpin, - ChipSetAction.delete, - ChipSetAction.rename, - ChipSetAction.hide, ]; - static const buttonActionCount = 2; @override void initState() { @@ -128,113 +125,78 @@ class _FilterGridAppBarState extends State _buildActions(AppMode appMode, Selection> selection) { - final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); - - PopupMenuItem toMenuItem(ChipSetAction action, {bool enabled = true}) { - return PopupMenuItem( - value: action, - enabled: enabled && actionDelegate.canApply(selectedFilters, action), - child: MenuRow(text: action.getText(context), icon: action.getIcon()), - ); - } - - void applyAction(ChipSetAction action) { - actionDelegate.onActionSelected(context, selectedFilters, action); - if (filterSelectionActions.contains(action)) { - selection.browse(); - } - } - + final itemCount = actionDelegate.allItems.length; final isSelecting = selection.isSelecting; - final selectionRowActions = []; + final selectedItems = selection.selectedItems; + final selectedFilters = selectedItems.map((v) => v.filter).toSet(); - final buttonActions = []; - if (isSelecting) { - final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); - final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList(); - buttonActions.addAll(validActions.take(buttonActionCount).map( - (action) { - final enabled = actionDelegate.canApply(selectedFilters, action); - return IconButton( - icon: action.getIcon(), - onPressed: enabled ? () => applyAction(action) : null, - tooltip: action.getText(context), - ); - }, - )); - selectionRowActions.addAll(validActions.skip(buttonActionCount)); - } else if (appMode.canSearch) { - buttonActions.add(CollectionSearchButton(source: source)); - } + bool isVisible(ChipSetAction action) => actionDelegate.isVisible( + action, + appMode: appMode, + isSelecting: isSelecting, + itemCount: itemCount, + selectedFilters: selectedFilters, + ); + bool canApply(ChipSetAction action) => actionDelegate.canApply( + action, + isSelecting: isSelecting, + itemCount: itemCount, + selectedFilters: selectedFilters, + ); + + final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map( + (action) => _toActionButton(action, enabled: canApply(action)), + ); return [ - ...buttonActions, + ...quickActionButtons, MenuIconTheme( child: PopupMenuButton( itemBuilder: (context) { - final selectedItems = selection.selectedItems; - final hasSelection = selectedItems.isNotEmpty; - final hasItems = !widget.isEmpty; - final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); + final generalMenuItems = ChipSetActions.general.where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ); - final menuItems = >[ - toMenuItem(ChipSetAction.sort), - if (widget.groupable) toMenuItem(ChipSetAction.group), - if (appMode == AppMode.main && !isSelecting) - toMenuItem( - ChipSetAction.select, - enabled: hasItems, - ), - ]; + final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v)); + final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v)); + final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( + (action) => _toMenuItem(action, enabled: canApply(action)), + ); - if (appMode == AppMode.main) { - menuItems.add(const PopupMenuDivider()); - if (isSelecting) { - menuItems.addAll(selectionRowActions.map(toMenuItem)); - } - menuItems.addAll([ - toMenuItem( - ChipSetAction.map, - enabled: otherViewEnabled, - ), - toMenuItem( - ChipSetAction.stats, - enabled: otherViewEnabled, - ), - ]); - if (!isSelecting && actionDelegate.isValid(selectedFilters, ChipSetAction.createAlbum)) { - menuItems.addAll([ - const PopupMenuDivider(), - toMenuItem(ChipSetAction.createAlbum), - ]); - } - } - if (isSelecting) { - menuItems.addAll([ + return [ + ...generalMenuItems, + if (contextualMenuItems.isNotEmpty) ...[ const PopupMenuDivider(), - toMenuItem( - ChipSetAction.selectAll, - enabled: selectedItems.length < actionDelegate.allItems.length, - ), - toMenuItem( - ChipSetAction.selectNone, - enabled: hasSelection, - ), - ]); - } - - return menuItems; + ...contextualMenuItems, + ], + ]; }, onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); - applyAction(action); + _onActionSelected(action); }, ), ), ]; } + Widget _toActionButton(ChipSetAction action, {required bool enabled}) { + return IconButton( + icon: action.getIcon(), + onPressed: enabled ? () => _onActionSelected(action) : null, + tooltip: action.getText(context), + ); + } + + PopupMenuItem _toMenuItem(ChipSetAction action, {required bool enabled}) { + return PopupMenuItem( + value: action, + enabled: enabled, + child: MenuRow(text: action.getText(context), icon: action.getIcon()), + ); + } + void _onActivityChange() { if (context.read>>().isSelecting) { _browseToSelectAnimation.forward(); @@ -243,6 +205,12 @@ class _FilterGridAppBarState extends State>>(); + final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); + actionDelegate.onActionSelected(context, selectedFilters, action); + } + void _goToSearch() { Navigator.push( context, diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 65b2d36df..7ce66ed98 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -15,8 +15,8 @@ class FilterNavigationPage extends StatelessWidget { final CollectionSource source; final String title; final ChipSortFactor sortFactor; - final bool groupable, showHeaders; - final ChipSetActionDelegate actionDelegate; + final bool showHeaders; + final ChipSetActionDelegate actionDelegate; final Map>> filterSections; final Set? newFilters; final Widget Function() emptyBuilder; @@ -26,7 +26,6 @@ class FilterNavigationPage extends StatelessWidget { required this.source, required this.title, required this.sortFactor, - this.groupable = false, this.showHeaders = false, required this.actionDelegate, required this.filterSections, @@ -43,7 +42,6 @@ class FilterNavigationPage extends StatelessWidget { source: source, title: title, actionDelegate: actionDelegate, - groupable: groupable, isEmpty: filterSections.isEmpty, ), sections: filterSections, @@ -72,7 +70,7 @@ class FilterNavigationPage extends StatelessWidget { builder: (context) => CollectionPage( collection: CollectionLens( source: source, - filters: [filter], + filters: {filter}, ), ), ), diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 22fd1363e..4569ce373 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -41,7 +41,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { AvesEntry? _viewerEntry; String? _shortcutRouteName, _shortcutSearchQuery; - List? _shortcutFilters; + Set? _shortcutFilters; static const allowedShortcutRoutes = [CollectionPage.routeName, AlbumListPage.routeName, SearchPage.routeName]; @@ -68,7 +68,12 @@ class _HomePageState extends State { } await androidFileUtils.init(); - unawaited(androidFileUtils.initAppNames()); + if (settings.isInstalledAppAccessAllowed) { + // TODO TLAD transition code (it's unset in v1.5.4), remove in a later release + settings.isInstalledAppAccessAllowed = settings.isInstalledAppAccessAllowed; + + unawaited(androidFileUtils.initAppNames()); + } var appMode = AppMode.main; final intentData = widget.intentData ?? await ViewerService.getIntentData(); @@ -103,7 +108,7 @@ class _HomePageState extends State { _shortcutRouteName = extraRoute; } final extraFilters = intentData['filters']; - _shortcutFilters = extraFilters != null ? (extraFilters as List).cast() : null; + _shortcutFilters = extraFilters != null ? (extraFilters as List).cast().toSet() : null; } } context.read>().value = appMode; @@ -147,12 +152,12 @@ class _HomePageState extends State { } String routeName; - Iterable? filters; + Set? filters; if (appMode == AppMode.pickExternal) { routeName = CollectionPage.routeName; } else { routeName = _shortcutRouteName ?? settings.homePage.routeName; - filters = (_shortcutFilters ?? []).map(CollectionFilter.fromJson); + filters = (_shortcutFilters ?? {}).map(CollectionFilter.fromJson).toSet(); } final source = context.read(); switch (routeName) { diff --git a/lib/widgets/map/map_info_row.dart b/lib/widgets/map/map_info_row.dart index ee267c138..b4d540a78 100644 --- a/lib/widgets/map/map_info_row.dart +++ b/lib/widgets/map/map_info_row.dart @@ -121,7 +121,7 @@ class _AddressRowState extends State<_AddressRow> { ? Constants.overlayUnknown : entry.hasAddress ? entry.shortAddress - : settings.coordinateFormat.format(entry.latLng!)); + : settings.coordinateFormat.format(context.l10n, entry.latLng!)); return Text( location, strutStyle: Constants.overflowStrutStyle, @@ -168,8 +168,10 @@ class _DateRow extends StatelessWidget { @override Widget build(BuildContext context) { final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + final date = entry?.bestDate; - final dateText = date != null ? formatDateTime(date, locale) : Constants.overlayUnknown; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; return Row( children: [ const SizedBox(width: MapInfoRow.iconPadding), diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart deleted file mode 100644 index dbda912df..000000000 --- a/lib/widgets/search/search_button.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/search/search_delegate.dart'; -import 'package:flutter/material.dart'; - -class CollectionSearchButton extends StatelessWidget { - final CollectionSource source; - final CollectionLens? parentCollection; - - const CollectionSearchButton({ - Key? key, - required this.source, - this.parentCollection, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return IconButton( - // key is expected by test driver - key: const Key('search-button'), - icon: const Icon(AIcons.search), - onPressed: () => _goToSearch(context), - tooltip: MaterialLocalizations.of(context).searchFieldLabel, - ); - } - - void _goToSearch(BuildContext context) { - Navigator.push( - context, - SearchPageRoute( - delegate: CollectionSearchDelegate( - source: source, - parentCollection: parentCollection, - ), - ), - ); - } -} diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 3796eb7a2..938b7ddde 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -259,7 +259,7 @@ class CollectionSearchDelegate { builder: (context) => CollectionPage( collection: CollectionLens( source: source, - filters: [filter], + filters: {filter}, ), ), ), diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index eb3662b3c..6c329871f 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -15,7 +15,7 @@ import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class QuickActionEditorPage extends StatefulWidget { +class QuickActionEditorPage extends StatelessWidget { final String title, bannerText; final List allAvailableActions; final Widget? Function(T action) actionIcon; @@ -35,10 +35,50 @@ class QuickActionEditorPage extends StatefulWidget { }) : super(key: key); @override - _QuickActionEditorPageState createState() => _QuickActionEditorPageState(); + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: SafeArea( + child: QuickActionEditorBody( + bannerText: bannerText, + allAvailableActions: allAvailableActions, + actionIcon: actionIcon, + actionText: actionText, + load: load, + save: save, + ), + ), + ), + ); + } } -class _QuickActionEditorPageState extends State> { +class QuickActionEditorBody extends StatefulWidget { + final String bannerText; + final List allAvailableActions; + final Widget? Function(T action) actionIcon; + final String Function(BuildContext context, T action) actionText; + final List Function() load; + final void Function(List actions) save; + + const QuickActionEditorBody({ + Key? key, + required this.bannerText, + required this.allAvailableActions, + required this.actionIcon, + required this.actionText, + required this.load, + required this.save, + }) : super(key: key); + + @override + _QuickActionEditorBodyState createState() => _QuickActionEditorBodyState(); +} + +class _QuickActionEditorBodyState extends State> with AutomaticKeepAliveClientMixin { final GlobalKey _animatedListKey = GlobalKey(debugLabel: 'quick-actions-animated-list'); Timer? _targetLeavingTimer; late List _quickActions; @@ -77,6 +117,8 @@ class _QuickActionEditorPageState extends State( placement: QuickActionPlacement.header, panelHighlight: _quickActionHighlight, @@ -95,135 +137,126 @@ class _QuickActionEditorPageState extends State( - valueListenable: _quickActionHighlight, - builder: (context, highlight, child) => ActionPanel( - highlight: highlight, - child: child!, - ), - child: SizedBox( - height: OverlayButton.getSize(context) + quickActionVerticalPadding * 2, - child: Stack( - children: [ - Positioned.fill( - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: .5, - child: header, - ), - ), - Positioned.fill( - child: FractionallySizedBox( - alignment: Alignment.centerRight, - widthFactor: .5, - child: footer, - ), - ), - Container( - alignment: Alignment.center, - child: AnimatedList( - key: _animatedListKey, - initialItemCount: _quickActions.length, - scrollDirection: Axis.horizontal, - shrinkWrap: true, - padding: EdgeInsets.zero, - itemBuilder: (context, index, animation) { - if (index >= _quickActions.length) return const SizedBox(); - final action = _quickActions[index]; - return QuickActionButton( - placement: QuickActionPlacement.action, - action: action, - panelHighlight: _quickActionHighlight, - draggedQuickAction: _draggedQuickAction, - draggedAvailableAction: _draggedAvailableAction, - insertAction: _insertQuickAction, - removeAction: _removeQuickAction, - onTargetLeave: _onQuickActionTargetLeave, - draggableFeedbackBuilder: (action) => ActionButton( - text: widget.actionText(context, action), - icon: widget.actionIcon(action), - showCaption: false, - ), - child: _buildQuickActionButton(action, animation), - ); - }, - ), - ), - AnimatedBuilder( - animation: _quickActionsChangeNotifier, - builder: (context, child) => _quickActions.isEmpty - ? Center( - child: Text( - context.l10n.settingsViewerQuickActionEmpty, - style: Theme.of(context).textTheme.caption, - ), - ) - : const SizedBox(), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - context.l10n.settingsViewerQuickActionEditorAvailableButtons, - style: Constants.titleTextStyle, - ), - ), - ValueListenableBuilder( - valueListenable: _availableActionHighlight, - builder: (context, highlight, child) => ActionPanel( - highlight: highlight, - child: child!, - ), - child: AvailableActionPanel( - allActions: widget.allAvailableActions, - quickActions: _quickActions, - quickActionsChangeNotifier: _quickActionsChangeNotifier, - panelHighlight: _availableActionHighlight, - draggedQuickAction: _draggedQuickAction, - draggedAvailableAction: _draggedAvailableAction, - removeQuickAction: _removeQuickAction, - actionIcon: widget.actionIcon, - actionText: widget.actionText, - ), - ), + const Icon(AIcons.info), + const SizedBox(width: 16), + Expanded(child: Text(widget.bannerText)), ], ), ), - ), + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.l10n.settingsViewerQuickActionEditorDisplayedButtons, + style: Constants.titleTextStyle, + ), + ), + ValueListenableBuilder( + valueListenable: _quickActionHighlight, + builder: (context, highlight, child) => ActionPanel( + highlight: highlight, + child: child!, + ), + child: SizedBox( + height: OverlayButton.getSize(context) + quickActionVerticalPadding * 2, + child: Stack( + children: [ + Positioned.fill( + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: .5, + child: header, + ), + ), + Positioned.fill( + child: FractionallySizedBox( + alignment: Alignment.centerRight, + widthFactor: .5, + child: footer, + ), + ), + Container( + alignment: Alignment.center, + child: AnimatedList( + key: _animatedListKey, + initialItemCount: _quickActions.length, + scrollDirection: Axis.horizontal, + shrinkWrap: true, + padding: EdgeInsets.zero, + itemBuilder: (context, index, animation) { + if (index >= _quickActions.length) return const SizedBox(); + final action = _quickActions[index]; + return QuickActionButton( + placement: QuickActionPlacement.action, + action: action, + panelHighlight: _quickActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + insertAction: _insertQuickAction, + removeAction: _removeQuickAction, + onTargetLeave: _onQuickActionTargetLeave, + draggableFeedbackBuilder: (action) => ActionButton( + text: widget.actionText(context, action), + icon: widget.actionIcon(action), + showCaption: false, + ), + child: _buildQuickActionButton(action, animation), + ); + }, + ), + ), + AnimatedBuilder( + animation: _quickActionsChangeNotifier, + builder: (context, child) => _quickActions.isEmpty + ? Center( + child: Text( + context.l10n.settingsViewerQuickActionEmpty, + style: Theme.of(context).textTheme.caption, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.l10n.settingsViewerQuickActionEditorAvailableButtons, + style: Constants.titleTextStyle, + ), + ), + ValueListenableBuilder( + valueListenable: _availableActionHighlight, + builder: (context, highlight, child) => ActionPanel( + highlight: highlight, + child: child!, + ), + child: AvailableActionPanel( + allActions: widget.allAvailableActions, + quickActions: _quickActions, + quickActionsChangeNotifier: _quickActionsChangeNotifier, + panelHighlight: _availableActionHighlight, + draggedQuickAction: _draggedQuickAction, + draggedAvailableAction: _draggedAvailableAction, + removeQuickAction: _removeQuickAction, + actionIcon: widget.actionIcon, + actionText: widget.actionText, + ), + ), + ], ), ); } @@ -284,7 +317,7 @@ class _QuickActionEditorPageState extends State extends State true; } diff --git a/lib/widgets/settings/language/language.dart b/lib/widgets/settings/language/language.dart index 066ec33f1..61c702bf7 100644 --- a/lib/widgets/settings/language/language.dart +++ b/lib/widgets/settings/language/language.dart @@ -23,6 +23,7 @@ class LanguageSection extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final currentCoordinateFormat = context.select((s) => s.coordinateFormat); final currentUnitSystem = context.select((s) => s.unitSystem); @@ -34,13 +35,13 @@ class LanguageSection extends StatelessWidget { icon: AIcons.language, color: stringToColor('Language'), ), - title: context.l10n.settingsSectionLanguage, + title: l10n.settingsSectionLanguage, expandedNotifier: expandedNotifier, showHighlight: false, children: [ const LocaleTile(), ListTile( - title: Text(context.l10n.settingsCoordinateFormatTile), + title: Text(l10n.settingsCoordinateFormatTile), subtitle: Text(currentCoordinateFormat.getName(context)), onTap: () async { final value = await showDialog( @@ -48,8 +49,8 @@ class LanguageSection extends StatelessWidget { builder: (context) => AvesSelectionDialog( initialValue: currentCoordinateFormat, options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.getName(context)))), - optionSubtitleBuilder: (value) => value.format(Constants.pointNemo), - title: context.l10n.settingsCoordinateFormatTitle, + optionSubtitleBuilder: (value) => value.format(l10n, Constants.pointNemo), + title: l10n.settingsCoordinateFormatTitle, ), ); if (value != null) { @@ -58,7 +59,7 @@ class LanguageSection extends StatelessWidget { }, ), ListTile( - title: Text(context.l10n.settingsUnitSystemTile), + title: Text(l10n.settingsUnitSystemTile), subtitle: Text(currentUnitSystem.getName(context)), onTap: () async { final value = await showDialog( @@ -66,7 +67,7 @@ class LanguageSection extends StatelessWidget { builder: (context) => AvesSelectionDialog( initialValue: currentUnitSystem, options: Map.fromEntries(UnitSystem.values.map((v) => MapEntry(v, v.getName(context)))), - title: context.l10n.settingsUnitSystemTitle, + title: l10n.settingsUnitSystemTitle, ), ); if (value != null) { diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 2ae5e4e0f..18526966e 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -46,6 +46,8 @@ class LocaleTile extends StatelessWidget { return 'English'; case 'ko': return '한국어'; + case 'ru': + return 'Русский'; } return locale.toString(); } diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 75a1a2523..701ef4860 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -1,3 +1,4 @@ +import 'package:aves/app_flavor.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; @@ -20,8 +21,7 @@ class PrivacySection extends StatelessWidget { @override Widget build(BuildContext context) { - final currentIsErrorReportingEnabled = context.select((s) => s.isErrorReportingEnabled); - final currentSaveSearchHistory = context.select((s) => s.saveSearchHistory); + final canEnableErrorReporting = context.select((v) => v.canEnableErrorReporting); return AvesExpansionTile( leading: SettingsTileLeading( @@ -32,20 +32,36 @@ class PrivacySection extends StatelessWidget { expandedNotifier: expandedNotifier, showHighlight: false, children: [ - SwitchListTile( - value: currentIsErrorReportingEnabled, - onChanged: (v) => settings.isErrorReportingEnabled = v, - title: Text(context.l10n.settingsEnableErrorReporting), + Selector( + selector: (context, s) => s.isInstalledAppAccessAllowed, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.isInstalledAppAccessAllowed = v, + title: Text(context.l10n.settingsAllowInstalledAppAccess), + subtitle: Text(context.l10n.settingsAllowInstalledAppAccessSubtitle), + ), ), - SwitchListTile( - value: currentSaveSearchHistory, - onChanged: (v) { - settings.saveSearchHistory = v; - if (!v) { - settings.searchHistory = []; - } - }, - title: Text(context.l10n.settingsSaveSearchHistory), + if (canEnableErrorReporting) + Selector( + selector: (context, s) => s.isErrorReportingAllowed, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.isErrorReportingAllowed = v, + title: Text(context.l10n.settingsAllowErrorReporting), + ), + ), + Selector( + selector: (context, s) => s.saveSearchHistory, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) { + settings.saveSearchHistory = v; + if (!v) { + settings.searchHistory = []; + } + }, + title: Text(context.l10n.settingsSaveSearchHistory), + ), ), const HiddenFilterTile(), const HiddenPathTile(), diff --git a/lib/widgets/settings/thumbnails/collection_actions_editor.dart b/lib/widgets/settings/thumbnails/collection_actions_editor.dart new file mode 100644 index 000000000..79c4594dc --- /dev/null +++ b/lib/widgets/settings/thumbnails/collection_actions_editor.dart @@ -0,0 +1,81 @@ +import 'package:aves/model/actions/entry_set_actions.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart'; +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; + +class CollectionActionsTile extends StatelessWidget { + const CollectionActionsTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsCollectionQuickActionsTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: CollectionActionEditorPage.routeName), + builder: (context) => const CollectionActionEditorPage(), + ), + ); + }, + ); + } +} + +class CollectionActionEditorPage extends StatelessWidget { + static const routeName = '/settings/collection_actions'; + + const CollectionActionEditorPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final tabs = >[ + Tuple2( + Tab(text: l10n.settingsCollectionQuickActionTabBrowsing), + QuickActionEditorBody( + bannerText: context.l10n.settingsCollectionBrowsingQuickActionEditorBanner, + allAvailableActions: EntrySetActions.browsing, + actionIcon: (action) => action.getIcon(), + actionText: (context, action) => action.getText(context), + load: () => settings.collectionBrowsingQuickActions.toList(), + save: (actions) => settings.collectionBrowsingQuickActions = actions, + ), + ), + Tuple2( + Tab(text: l10n.settingsCollectionQuickActionTabSelecting), + QuickActionEditorBody( + bannerText: context.l10n.settingsCollectionSelectionQuickActionEditorBanner, + allAvailableActions: EntrySetActions.selection, + actionIcon: (action) => action.getIcon(), + actionText: (context, action) => action.getText(context), + load: () => settings.collectionSelectionQuickActions.toList(), + save: (actions) => settings.collectionSelectionQuickActions = actions, + ), + ), + ]; + + return MediaQueryDataProvider( + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsCollectionQuickActionEditorTitle), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), + ), + ), + body: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/thumbnails/selection_actions_editor.dart b/lib/widgets/settings/thumbnails/selection_actions_editor.dart deleted file mode 100644 index 218736adc..000000000 --- a/lib/widgets/settings/thumbnails/selection_actions_editor.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:aves/model/actions/entry_set_actions.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/settings/common/quick_actions/editor_page.dart'; -import 'package:flutter/material.dart'; - -class SelectionActionsTile extends StatelessWidget { - const SelectionActionsTile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(context.l10n.settingsCollectionSelectionQuickActionsTile), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: SelectionActionEditorPage.routeName), - builder: (context) => const SelectionActionEditorPage(), - ), - ); - }, - ); - } -} - -class SelectionActionEditorPage extends StatelessWidget { - static const routeName = '/settings/collection_selection_actions'; - - const SelectionActionEditorPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return QuickActionEditorPage( - title: context.l10n.settingsCollectionSelectionQuickActionEditorTitle, - bannerText: context.l10n.settingsCollectionSelectionQuickActionEditorBanner, - allAvailableActions: EntrySetActions.selection, - actionIcon: (action) => action.getIcon(), - actionText: (context, action) => action.getText(context), - load: () => settings.collectionSelectionQuickActions.toList(), - save: (actions) => settings.collectionSelectionQuickActions = actions, - ); - } -} diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index 9d8fd5434..e2e167a76 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -6,7 +6,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; -import 'package:aves/widgets/settings/thumbnails/selection_actions_editor.dart'; +import 'package:aves/widgets/settings/thumbnails/collection_actions_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -37,7 +37,7 @@ class ThumbnailsSection extends StatelessWidget { expandedNotifier: expandedNotifier, showHighlight: false, children: [ - const SelectionActionsTile(), + const CollectionActionsTile(), SwitchListTile( value: currentShowThumbnailLocation, onChanged: (v) => settings.showThumbnailLocation = v, diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index f0b048731..11e383422 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -39,9 +39,9 @@ class ViewerActionEditorPage extends StatelessWidget { EntryAction.copyToClipboard, EntryAction.print, EntryAction.rotateScreen, - EntryAction.flip, EntryAction.rotateCCW, EntryAction.rotateCW, + EntryAction.flip, ]; @override diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index bf32ce914..f80232d90 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -22,6 +22,7 @@ import 'package:charts_flutter/flutter.dart' as charts; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:provider/provider.dart'; @@ -78,8 +79,8 @@ class StatsPage extends StatelessWidget { alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - _buildMimeDonut(context, (sum) => context.l10n.statsImage(sum), imagesByMimeTypes, animate), - _buildMimeDonut(context, (sum) => context.l10n.statsVideo(sum), videoByMimeTypes, animate), + _buildMimeDonut(context, AIcons.image, imagesByMimeTypes, animate), + _buildMimeDonut(context, AIcons.video, videoByMimeTypes, animate), ], ); @@ -108,7 +109,10 @@ class StatsPage extends StatelessWidget { ), ), const SizedBox(height: 8), - Text(context.l10n.statsWithGps(withGpsCount)), + Text( + context.l10n.statsWithGps(withGpsCount), + textAlign: TextAlign.center, + ), ], ), ); @@ -136,7 +140,7 @@ class StatsPage extends StatelessWidget { Widget _buildMimeDonut( BuildContext context, - String Function(int) label, + IconData icon, Map byMimeTypes, bool animate, ) { @@ -180,9 +184,15 @@ class StatsPage extends StatelessWidget { ), ), Center( - child: Text( - '$sum\n${label(sum)}', - textAlign: TextAlign.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon), + Text( + '$sum', + textAlign: TextAlign.center, + ), + ], ), ), ], @@ -285,7 +295,7 @@ class StatsPage extends StatelessWidget { builder: (context) => CollectionPage( collection: CollectionLens( source: source, - filters: [filter], + filters: {filter}, ), ), ), diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index bc150c957..d5dcb04a1 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; +import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; @@ -23,6 +24,7 @@ class _DbTabState extends State { late Future _dbEntryLoader; late Future _dbMetadataLoader; late Future _dbAddressLoader; + late Future _dbVideoPlaybackLoader; AvesEntry get entry => widget.entry; @@ -35,9 +37,10 @@ class _DbTabState extends State { void _loadDatabase() { final contentId = entry.contentId; _dbDateLoader = metadataDb.loadDates().then((values) => values[contentId]); - _dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); - _dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); - _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); + _dbEntryLoader = metadataDb.loadAllEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); + _dbMetadataLoader = metadataDb.loadAllMetadataEntries().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); + _dbAddressLoader = metadataDb.loadAllAddresses().then((values) => values.firstWhereOrNull((row) => row.contentId == contentId)); + _dbVideoPlaybackLoader = metadataDb.loadVideoPlayback(contentId); setState(() {}); } @@ -150,6 +153,27 @@ class _DbTabState extends State { ); }, ), + const SizedBox(height: 16), + FutureBuilder( + future: _dbVideoPlaybackLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + final data = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('DB video playback:${data == null ? ' no row' : ''}'), + if (data != null) + InfoRowGroup( + info: { + 'resumeTimeMillis': '${data.resumeTimeMillis}', + }, + ), + ], + ); + }, + ), ], ); } diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 40ac87f35..35ee00ca6 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -597,7 +597,8 @@ class _EntryViewerStackState extends State with FeedbackMixin, setState(() {}); if (settings.enableVideoAutoPlay) { - await _playVideo(controller, () => entry == _entryNotifier.value); + final resumeTimeMillis = await controller.getResumeTime(context); + await _playVideo(controller, () => entry == _entryNotifier.value, resumeTimeMillis: resumeTimeMillis); } } @@ -649,13 +650,17 @@ class _EntryViewerStackState extends State with FeedbackMixin, } } - Future _playVideo(AvesVideoController videoController, bool Function() isCurrent) async { + Future _playVideo(AvesVideoController videoController, bool Function() isCurrent, {int? resumeTimeMillis}) async { // video decoding may fail or have initial artifacts when the player initializes // during this widget initialization (because of the page transition and hero animation?) // so we play after a delay for increased stability await Future.delayed(const Duration(milliseconds: 300) * timeDilation); - await videoController.play(); + if (resumeTimeMillis != null) { + await videoController.seekTo(resumeTimeMillis); + } else { + await videoController.play(); + } // playing controllers are paused when the entry changes, // but the controller may still be preparing (not yet playing) when this happens diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 8fa125ab9..b8d062669 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -17,6 +17,7 @@ import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class BasicSection extends StatelessWidget { final AvesEntry entry; @@ -41,6 +42,7 @@ class BasicSection extends StatelessWidget { final l10n = context.l10n; final infoUnknown = l10n.viewerInfoUnknown; final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); return AnimatedBuilder( animation: entry.metadataChangeNotifier, @@ -49,7 +51,7 @@ class BasicSection extends StatelessWidget { // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) final title = entry.bestTitle ?? infoUnknown; final date = entry.bestDate; - final dateText = date != null ? formatDateTime(date, locale) : infoUnknown; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : infoUnknown; final showResolution = !entry.isSvg && entry.isSized; final sizeText = entry.sizeBytes != null ? formatFilesize(entry.sizeBytes!) : infoUnknown; final path = entry.path; diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index 2992a711d..f5f9d0d13 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -1,20 +1,16 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata/date_modifier.dart'; -import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; -import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { +class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; const EntryInfoActionDelegate(this.entry); @@ -22,10 +18,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { void onActionSelected(BuildContext context, EntryInfoAction action) async { switch (action) { case EntryInfoAction.editDate: - await _showDateEditDialog(context); + await _editDate(context); break; case EntryInfoAction.removeMetadata: - await _showMetadataRemovalDialog(context); + await _removeMetadata(context); break; } } @@ -52,45 +48,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin { source?.resumeMonitoring(); } - Future _showDateEditDialog(BuildContext context) async { - final modifier = await showDialog( - context: context, - builder: (context) => EditEntryDateDialog(entry: entry), - ); + Future _editDate(BuildContext context) async { + final modifier = await selectDateModifier(context, {entry}); if (modifier == null) return; await _edit(context, () => entry.editDate(modifier)); } - Future _showMetadataRemovalDialog(BuildContext context) async { - final types = await showDialog>( - context: context, - builder: (context) => RemoveEntryMetadataDialog(entry: entry), - ); - if (types == null || types.isEmpty) return; - - if (entry.isMotionPhoto && types.contains(MetadataType.xmp)) { - final proceed = await showDialog( - context: context, - builder: (context) { - return AvesDialog( - context: context, - content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(context.l10n.applyButtonLabel), - ), - ], - ); - }, - ); - if (proceed == null || !proceed) return; - } + Future _removeMetadata(BuildContext context) async { + final types = await selectMetadataToRemove(context, {entry}); + if (types == null) return; await _edit(context, () => entry.removeMetadata(types)); } diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index f830ccdbb..fa78e6da9 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -174,7 +174,7 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> { final l10n = context.l10n; return InfoRowGroup( info: { - l10n.viewerInfoLabelCoordinates: settings.coordinateFormat.format(entry.latLng!), + l10n.viewerInfoLabelCoordinates: settings.coordinateFormat.format(l10n, entry.latLng!), if (address.isNotEmpty) l10n.viewerInfoLabelAddress: address, }, ); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart index 09884366d..8cee96098 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart @@ -8,11 +8,13 @@ class XmpCrsNamespace extends XmpNamespace { static final cgbcPattern = RegExp(ns + r':CircularGradientBasedCorrections\[(\d+)\]/(.*)'); static final gbcPattern = RegExp(ns + r':GradientBasedCorrections\[(\d+)\]/(.*)'); static final pbcPattern = RegExp(ns + r':PaintBasedCorrections\[(\d+)\]/(.*)'); + static final retouchAreasPattern = RegExp(ns + r':RetouchAreas\[(\d+)\]/(.*)'); static final lookPattern = RegExp(ns + r':Look/(.*)'); final cgbc = >{}; final gbc = >{}; final pbc = >{}; + final retouchAreas = >{}; final look = {}; XmpCrsNamespace(Map rawProps) : super(ns, rawProps); @@ -23,6 +25,7 @@ class XmpCrsNamespace extends XmpNamespace { var hasIndexedStructs = extractIndexedStruct(prop, cgbcPattern, cgbc); hasIndexedStructs |= extractIndexedStruct(prop, gbcPattern, gbc); hasIndexedStructs |= extractIndexedStruct(prop, pbcPattern, pbc); + hasIndexedStructs |= extractIndexedStruct(prop, retouchAreasPattern, retouchAreas); return hasStructs || hasIndexedStructs; } @@ -48,5 +51,10 @@ class XmpCrsNamespace extends XmpNamespace { title: 'Paint Based Corrections', structByIndex: pbc, ), + if (retouchAreas.isNotEmpty) + XmpStructArrayCard( + title: 'Retouch Areas', + structByIndex: retouchAreas, + ), ]; } diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 82c264bf0..fa9ec0861 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -323,7 +323,7 @@ class _LocationRow extends AnimatedWidget { @override Widget build(BuildContext context) { - final location = entry.hasAddress ? entry.shortAddress : settings.coordinateFormat.format(entry.latLng!); + final location = entry.hasAddress ? entry.shortAddress : settings.coordinateFormat.format(context.l10n, entry.latLng!); return Row( children: [ const DecoratedIcon(AIcons.location, shadows: Constants.embossShadows, size: _iconSize), @@ -395,8 +395,10 @@ class _DateRow extends StatelessWidget { @override Widget build(BuildContext context) { final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + final date = entry.bestDate; - final dateText = date != null ? formatDateTime(date, locale) : Constants.overlayUnknown; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown; final resolutionText = entry.isSvg ? entry.aspectRatioText : entry.isSized diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 03c055fca..8a4208b8d 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; @@ -73,9 +72,9 @@ class _VideoControlOverlayState extends State with SingleTi child: OverlayButton( scale: scale, child: IconButton( - icon: const Icon(AIcons.openOutside), - onPressed: () => androidAppService.open(entry.uri, entry.mimeTypeAnySubtype), - tooltip: context.l10n.viewerOpenTooltip, + icon: VideoAction.playOutside.getIcon(), + onPressed: () => widget.onActionSelected(VideoAction.playOutside), + tooltip: VideoAction.playOutside.getText(context), ), ), ); @@ -292,6 +291,7 @@ class _ButtonRow extends StatelessWidget { onPressed: onPressed, ); break; + case VideoAction.playOutside: case VideoAction.replay10: case VideoAction.skip10: case VideoAction.settings: @@ -323,6 +323,7 @@ class _ButtonRow extends StatelessWidget { case VideoAction.setSpeed: enabled = controller?.canSetSpeedNotifier.value ?? false; break; + case VideoAction.playOutside: case VideoAction.replay10: case VideoAction.skip10: case VideoAction.settings: @@ -340,6 +341,7 @@ class _ButtonRow extends StatelessWidget { ); break; case VideoAction.captureFrame: + case VideoAction.playOutside: case VideoAction.replay10: case VideoAction.skip10: case VideoAction.selectStreams: diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index 46a0dff56..9db7aec24 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -60,7 +60,7 @@ class EntryPrinter with FeedbackMixin { for (var page = 0; page < pageCount; page++) { final pageEntry = multiPageInfo.getPageEntryByIndex(page); _addPdfPage(await _buildPageImage(pageEntry)); - streamController.sink.add(pageEntry); + streamController.add(pageEntry); } await streamController.close(); } diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index 4b9a32ba8..f3bccd193 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -1,6 +1,11 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/video_playback.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/format.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,7 +16,65 @@ abstract class AvesVideoController { AvesVideoController(AvesEntry entry) : _entry = entry; - Future dispose(); + static const resumeTimeSaveMinProgress = .05; + static const resumeTimeSaveMaxProgress = .95; + static const resumeTimeSaveMinDuration = Duration(minutes: 2); + + @mustCallSuper + Future dispose() async { + await _savePlaybackState(); + } + + Future _savePlaybackState() async { + final contentId = entry.contentId; + if (contentId == null || !isReady || duration < resumeTimeSaveMinDuration.inMilliseconds) return; + + final _progress = progress; + if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) { + await metadataDb.addVideoPlayback({ + VideoPlaybackRow( + contentId: contentId, + resumeTimeMillis: currentPosition, + ) + }); + } else { + await metadataDb.removeVideoPlayback({contentId}); + } + } + + Future getResumeTime(BuildContext context) async { + final contentId = entry.contentId; + if (contentId == null) return null; + + final playback = await metadataDb.loadVideoPlayback(contentId); + final resumeTime = playback?.resumeTimeMillis ?? 0; + if (resumeTime == 0) return null; + + // clear on retrieval + await metadataDb.removeVideoPlayback({contentId}); + + final resume = await showDialog( + context: context, + builder: (context) { + return AvesDialog( + context: context, + content: Text(context.l10n.videoResumeDialogMessage(formatFriendlyDuration(Duration(milliseconds: resumeTime)))), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.videoStartOverButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.videoResumeButtonLabel), + ), + ], + ); + }, + ); + if (resume == null || !resume) return 0; + return resumeTime; + } Future play(); diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 6801b545c..6db787e10 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -53,11 +53,12 @@ class IjkPlayerAvesVideoController extends AvesVideoController { static const initialPlayDelay = Duration(milliseconds: 100); static const gifLikeVideoDurationThreshold = Duration(seconds: 10); static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s) + static const captureFrameEnabled = true; IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) { _instance = FijkPlayer(); _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( - (started) => canCaptureFrameNotifier.value = started, + (started) => canCaptureFrameNotifier.value = captureFrameEnabled && started, onError: (error) {}, ); _valueStream.map((value) => value.audioRenderStart).firstWhere((v) => v, orElse: () => false).then( @@ -69,6 +70,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { @override Future dispose() async { + await super.dispose(); _initialPlayTimer?.cancel(); _stopListening(); await _valueStreamController.close(); @@ -144,7 +146,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { // default: 0, in [0, 1] // cf https://fijkplayer.befovy.com/docs/zh/host-option.html // there is a performance cost, and it should be set up before playing - options.setHostOption('enable-snapshot', 1); + options.setHostOption('enable-snapshot', captureFrameEnabled ? 1 : 0); // `accurate-seek-timeout`: accurate seek timeout // default: 5000 ms, in [0, 5000] diff --git a/lib/widgets/viewer/video_action_delegate.dart b/lib/widgets/viewer/video_action_delegate.dart index 6f87bef12..494cfc7c7 100644 --- a/lib/widgets/viewer/video_action_delegate.dart +++ b/lib/widgets/viewer/video_action_delegate.dart @@ -43,6 +43,10 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case VideoAction.captureFrame: _captureFrame(context, controller); break; + case VideoAction.playOutside: + final entry = controller.entry; + androidAppService.open(entry.uri, entry.mimeTypeAnySubtype); + break; case VideoAction.replay10: if (controller.isReady) controller.seekTo(controller.currentPosition - 10000); break; @@ -181,7 +185,12 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (controller.isPlaying) { await controller.pause(); } else { - await controller.play(); + final resumeTimeMillis = await controller.getResumeTime(context); + if (resumeTimeMillis != null) { + await controller.seekTo(resumeTimeMillis); + } else { + await controller.play(); + } // hide overlay _overlayHidingTimer = Timer(context.read().iconAnimation + Durations.videoOverlayHideDelay, () { const ToggleOverlayNotification(visible: false).dispatch(context); diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 91a3c3361..ba167a5ef 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -1,18 +1,19 @@ +import 'package:aves/app_flavor.dart'; +import 'package:aves/model/settings/defaults.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/basic/labeled_checkbox.dart'; +import 'package:aves/widgets/common/basic/markdown_container.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; class WelcomePage extends StatefulWidget { const WelcomePage({Key? key}) : super(key: key); @@ -30,6 +31,15 @@ class _WelcomePageState extends State { super.initState(); settings.setContextualDefaults(); _termsLoader = rootBundle.loadString('assets/terms.md'); + WidgetsBinding.instance!.addPostFrameCallback((_) => _initWelcomeSettings()); + } + + // explicitly set consent values to current defaults + // so they are not subject to future default changes + void _initWelcomeSettings() { + // this should be done outside of `initState`/`build` + settings.isInstalledAppAccessAllowed = SettingsDefaults.isInstalledAppAccessAllowed; + settings.isErrorReportingAllowed = SettingsDefaults.isErrorReportingAllowed; } @override @@ -37,15 +47,14 @@ class _WelcomePageState extends State { return MediaQueryDataProvider( child: Scaffold( body: SafeArea( - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16.0), + child: Center( 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 const SizedBox(); final terms = snapshot.data!; final durations = context.watch(); + final isPortrait = context.select((mq) => mq.orientation) == Orientation.portrait; return Column( mainAxisSize: MainAxisSize.min, children: _toStaggeredList( @@ -58,10 +67,29 @@ class _WelcomePageState extends State { ), ), children: [ - ..._buildTop(context), - Flexible(child: _buildTerms(terms)), - const SizedBox(height: 16), - ..._buildBottomControls(context), + ..._buildHeader(context, isPortrait: isPortrait), + if (isPortrait) ...[ + Flexible(child: MarkdownContainer(data: terms)), + const SizedBox(height: 16), + ..._buildControls(context), + ] else + Flexible( + child: Row( + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MarkdownContainer(data: terms), + )), + Flexible( + child: ListView( + // shrinkWrap: true, + children: _buildControls(context), + ), + ) + ], + ), + ) ], ), ); @@ -73,13 +101,15 @@ class _WelcomePageState extends State { ); } - List _buildTop(BuildContext context) { + List _buildHeader(BuildContext context, {required bool isPortrait}) { final message = Text( context.l10n.welcomeMessage, style: Theme.of(context).textTheme.headline5, ); + final padding = isPortrait ? 16.0 : 8.0; return [ - ...(context.select((mq) => mq.orientation) == Orientation.portrait + SizedBox(height: padding), + ...(isPortrait ? [ const AvesLogo(size: 64), const SizedBox(height: 16), @@ -95,36 +125,50 @@ class _WelcomePageState extends State { ], ) ]), - const SizedBox(height: 16), + SizedBox(height: padding), ]; } - List _buildBottomControls(BuildContext context) { - final checkboxes = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LabeledCheckbox( - value: settings.isErrorReportingEnabled, - onChanged: (v) { - if (v != null) setState(() => settings.isErrorReportingEnabled = v); - }, - text: context.l10n.welcomeCrashReportToggle, - ), - LabeledCheckbox( - // key is expected by test driver - key: const Key('agree-checkbox'), - value: _hasAcceptedTerms, - onChanged: (v) { - if (v != null) setState(() => _hasAcceptedTerms = v); - }, - text: context.l10n.welcomeTermsToggle, - ), - ], + List _buildControls(BuildContext context) { + final l10n = context.l10n; + final canEnableErrorReporting = context.select((v) => v.canEnableErrorReporting); + const contentPadding = EdgeInsets.symmetric(horizontal: 8); + final switches = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: MarkdownContainer.maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + value: settings.isInstalledAppAccessAllowed, + onChanged: (v) => setState(() => settings.isInstalledAppAccessAllowed = v), + title: Text(l10n.settingsAllowInstalledAppAccess), + subtitle: Text([l10n.welcomeOptional, l10n.settingsAllowInstalledAppAccessSubtitle].join(' • ')), + contentPadding: contentPadding, + ), + if (canEnableErrorReporting) + SwitchListTile( + value: settings.isErrorReportingAllowed, + onChanged: (v) => setState(() => settings.isErrorReportingAllowed = v), + title: Text(l10n.settingsAllowErrorReporting), + subtitle: Text(l10n.welcomeOptional), + contentPadding: contentPadding, + ), + SwitchListTile( + // key is expected by test driver + key: const Key('agree-checkbox'), + value: _hasAcceptedTerms, + onChanged: (v) => setState(() => _hasAcceptedTerms = v), + title: Text(l10n.welcomeTermsToggle), + contentPadding: contentPadding, + ), + ], + ), ); - final button = ElevatedButton( + final button = AvesOutlinedButton( // key is expected by test driver key: const Key('continue-button'), + label: context.l10n.continueButtonLabel, onPressed: _hasAcceptedTerms ? () { settings.hasAcceptedTerms = true; @@ -137,60 +181,13 @@ class _WelcomePageState extends State { ); } : null, - child: Text(context.l10n.continueButtonLabel), ); - return context.select((mq) => mq.orientation) == Orientation.portrait - ? [ - checkboxes, - button, - ] - : [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - checkboxes, - const Spacer(), - button, - ], - ), - ]; - } - - Widget _buildTerms(String terms) { - return Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(16)), - color: Colors.white10, - ), - constraints: const BoxConstraints(maxWidth: 460), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(16)), - child: Theme( - data: Theme.of(context).copyWith( - scrollbarTheme: const ScrollbarThemeData( - isAlwaysShown: true, - radius: Radius.circular(16), - crossAxisMargin: 6, - mainAxisMargin: 16, - interactive: true, - ), - ), - child: Scrollbar( - child: Markdown( - data: terms, - selectable: true, - onTapLink: (text, href, title) async { - if (href != null && await canLaunch(href)) { - await launch(href); - } - }, - shrinkWrap: true, - ), - ), - ), - ), - ); + return [ + switches, + Center(child: button), + const SizedBox(height: 8), + ]; } // as of flutter_staggered_animations v0.1.2, `AnimationConfiguration.toStaggeredList` does not handle `Flexible` widgets diff --git a/plugins/aves_report/.gitignore b/plugins/aves_report/.gitignore new file mode 100644 index 000000000..e9dc58d3d --- /dev/null +++ b/plugins/aves_report/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +build/ diff --git a/plugins/aves_report/.metadata b/plugins/aves_report/.metadata new file mode 100644 index 000000000..5bed5265e --- /dev/null +++ b/plugins/aves_report/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 18116933e77adc82f80866c928266a5b4f1ed645 + channel: stable + +project_type: plugin diff --git a/plugins/aves_report/analysis_options.yaml b/plugins/aves_report/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/plugins/aves_report/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/plugins/aves_report/lib/aves_report.dart b/plugins/aves_report/lib/aves_report.dart new file mode 100644 index 000000000..5c0cf8999 --- /dev/null +++ b/plugins/aves_report/lib/aves_report.dart @@ -0,0 +1,21 @@ +library aves_report; + +import 'package:flutter/foundation.dart'; + +abstract class ReportService { + Future init(); + + Map get state; + + Future setCollectionEnabled(bool enabled); + + Future log(String message); + + Future setCustomKey(String key, Object value); + + Future setCustomKeys(Map map); + + Future recordError(dynamic exception, StackTrace? stack); + + Future recordFlutterError(FlutterErrorDetails flutterErrorDetails); +} diff --git a/plugins/aves_report/pubspec.lock b/plugins/aves_report/pubspec.lock new file mode 100644 index 000000000..0757de291 --- /dev/null +++ b/plugins/aves_report/pubspec.lock @@ -0,0 +1,65 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/plugins/aves_report/pubspec.yaml b/plugins/aves_report/pubspec.yaml new file mode 100644 index 000000000..5ed692860 --- /dev/null +++ b/plugins/aves_report/pubspec.yaml @@ -0,0 +1,16 @@ +name: aves_report +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_lints: + +flutter: diff --git a/plugins/aves_report_console/.gitignore b/plugins/aves_report_console/.gitignore new file mode 100644 index 000000000..a247422ef --- /dev/null +++ b/plugins/aves_report_console/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/plugins/aves_report_console/.metadata b/plugins/aves_report_console/.metadata new file mode 100644 index 000000000..db56104b2 --- /dev/null +++ b/plugins/aves_report_console/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 18116933e77adc82f80866c928266a5b4f1ed645 + channel: stable + +project_type: package diff --git a/plugins/aves_report_console/analysis_options.yaml b/plugins/aves_report_console/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/plugins/aves_report_console/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/plugins/aves_report_console/lib/aves_report_platform.dart b/plugins/aves_report_console/lib/aves_report_platform.dart new file mode 100644 index 000000000..3f1237d65 --- /dev/null +++ b/plugins/aves_report_console/lib/aves_report_platform.dart @@ -0,0 +1,30 @@ +library aves_report_platform; + +import 'package:aves_report/aves_report.dart'; +import 'package:flutter/foundation.dart'; + +class PlatformReportService extends ReportService { + @override + Future init() => SynchronousFuture(null); + + @override + Future log(String message) async => debugPrint('Report log with message=$message'); + + @override + Future recordError(exception, StackTrace? stack) async => debugPrint('Report error with exception=$exception, stack=$stack'); + + @override + Future recordFlutterError(FlutterErrorDetails flutterErrorDetails) async => debugPrint('Report Flutter error with details=$flutterErrorDetails'); + + @override + Future setCollectionEnabled(bool enabled) => SynchronousFuture(null); + + @override + Future setCustomKey(String key, Object value) async => debugPrint('Report set key $key=$value'); + + @override + Future setCustomKeys(Map map) async => debugPrint('Report set keys ${map.entries.map((kv) => '${kv.key}=${kv.value}').join(', ')}'); + + @override + Map get state => {'Reporter': 'Console'}; +} diff --git a/plugins/aves_report_console/pubspec.lock b/plugins/aves_report_console/pubspec.lock new file mode 100644 index 000000000..dcd186473 --- /dev/null +++ b/plugins/aves_report_console/pubspec.lock @@ -0,0 +1,140 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + aves_report: + dependency: "direct main" + description: + path: "../aves_report" + relative: true + source: path + version: "0.0.1" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + firebase_core: + dependency: transitive + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + firebase_crashlytics: + dependency: transitive + description: + name: firebase_crashlytics + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.4" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/plugins/aves_report_console/pubspec.yaml b/plugins/aves_report_console/pubspec.yaml new file mode 100644 index 000000000..00e9bccb9 --- /dev/null +++ b/plugins/aves_report_console/pubspec.yaml @@ -0,0 +1,18 @@ +name: aves_report_platform +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + aves_report: + path: ../aves_report + +dev_dependencies: + flutter_lints: + +flutter: diff --git a/plugins/aves_report_crashlytics/.gitignore b/plugins/aves_report_crashlytics/.gitignore new file mode 100644 index 000000000..a247422ef --- /dev/null +++ b/plugins/aves_report_crashlytics/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/plugins/aves_report_crashlytics/.metadata b/plugins/aves_report_crashlytics/.metadata new file mode 100644 index 000000000..db56104b2 --- /dev/null +++ b/plugins/aves_report_crashlytics/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 18116933e77adc82f80866c928266a5b4f1ed645 + channel: stable + +project_type: package diff --git a/plugins/aves_report_crashlytics/analysis_options.yaml b/plugins/aves_report_crashlytics/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/plugins/aves_report_crashlytics/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/lib/services/report_service.dart b/plugins/aves_report_crashlytics/lib/aves_report_platform.dart similarity index 77% rename from lib/services/report_service.dart rename to plugins/aves_report_crashlytics/lib/aves_report_platform.dart index ea024b743..b091a2f31 100644 --- a/lib/services/report_service.dart +++ b/plugins/aves_report_crashlytics/lib/aves_report_platform.dart @@ -1,3 +1,8 @@ +library aves_report_platform; + +import 'dart:async'; + +import 'package:aves_report/aves_report.dart'; import 'package:collection/collection.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; @@ -5,32 +10,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:stack_trace/stack_trace.dart'; -abstract class ReportService { - Future init(); - - bool get isCollectionEnabled; - - Future setCollectionEnabled(bool enabled); - - Future log(String message); - - Future setCustomKey(String key, Object value); - - Future setCustomKeys(Map map); - - Future recordError(dynamic exception, StackTrace? stack); - - Future recordFlutterError(FlutterErrorDetails flutterErrorDetails); -} - -class CrashlyticsReportService extends ReportService { +class PlatformReportService extends ReportService { FirebaseCrashlytics get _instance => FirebaseCrashlytics.instance; @override Future init() => Firebase.initializeApp(); @override - bool get isCollectionEnabled => _instance.isCrashlyticsCollectionEnabled; + Map get state => { + 'Reporter': 'Crashlytics', + 'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}', + 'Crashlytics collection enabled': '${_instance.isCrashlyticsCollectionEnabled}', + }; @override Future setCollectionEnabled(bool enabled) async { diff --git a/plugins/aves_report_crashlytics/pubspec.lock b/plugins/aves_report_crashlytics/pubspec.lock new file mode 100644 index 000000000..22d18caae --- /dev/null +++ b/plugins/aves_report_crashlytics/pubspec.lock @@ -0,0 +1,140 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + aves_report: + dependency: "direct main" + description: + path: "../aves_report" + relative: true + source: path + version: "0.0.1" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.4" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.4" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" diff --git a/plugins/aves_report_crashlytics/pubspec.yaml b/plugins/aves_report_crashlytics/pubspec.yaml new file mode 100644 index 000000000..c0ba30e16 --- /dev/null +++ b/plugins/aves_report_crashlytics/pubspec.yaml @@ -0,0 +1,20 @@ +name: aves_report_platform +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + aves_report: + path: ../aves_report + firebase_core: + firebase_crashlytics: + +dev_dependencies: + flutter_lints: + +flutter: diff --git a/pubspec.lock b/pubspec.lock index 21a920749..1e5f37320 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "29.0.0" + version: "30.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.6.0" + version: "2.7.0" archive: dependency: transitive description: @@ -36,6 +36,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.1" + aves_report: + dependency: "direct main" + description: + path: "plugins/aves_report" + relative: true + source: path + version: "0.0.1" + aves_report_platform: + dependency: "direct main" + description: + path: "plugins/aves_report_crashlytics" + relative: true + source: path + version: "0.0.1" barcode: dependency: transitive description: @@ -70,16 +84,14 @@ packages: name: charts_common url: "https://pub.dartlang.org" source: hosted - version: "0.11.0" + version: "0.12.0" charts_flutter: dependency: "direct main" description: - path: charts_flutter - ref: HEAD - resolved-ref: "30477090290b348ed3101bc13017aae465f59017" - url: "git://github.com/google/charts.git" - source: git - version: "0.11.0" + name: charts_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0" cli_util: dependency: transitive description: @@ -198,7 +210,7 @@ packages: name: device_info_plus url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "3.1.1" device_info_plus_linux: dependency: transitive description: @@ -219,7 +231,7 @@ packages: name: device_info_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "2.2.1" device_info_plus_web: dependency: transitive description: @@ -276,7 +288,7 @@ packages: description: path: "." ref: aves - resolved-ref: "1d10ebbdcd71a2d9970dd6cd1f3cf6315bb686e6" + resolved-ref: "44569361c251cc4ced0ff845b02c64ceeaebb957" url: "git://github.com/deckerst/fijkplayer.git" source: git version: "0.10.0" @@ -288,40 +300,40 @@ packages: source: hosted version: "6.1.2" firebase_core: - dependency: "direct main" + dependency: transitive description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "1.7.0" + version: "1.10.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" + version: "4.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" firebase_crashlytics: - dependency: "direct main" + dependency: transitive description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "2.2.3" + version: "2.3.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.1.3" + version: "3.1.6" flex_color_picker: dependency: "direct main" description: @@ -385,14 +397,14 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.6.7" + version: "0.6.8" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" flutter_staggered_animations: dependency: "direct main" description: @@ -435,7 +447,7 @@ packages: name: github url: "https://pub.dartlang.org" source: hosted - version: "8.2.1" + version: "8.2.3" glob: dependency: transitive description: @@ -456,7 +468,7 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.1.0" google_maps_flutter_platform_interface: dependency: transitive description: @@ -596,7 +608,7 @@ packages: name: mime url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.1" motion_sensors: dependency: transitive description: @@ -736,7 +748,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "3.6.0" + version: "3.6.1" pedantic: dependency: transitive description: @@ -757,7 +769,7 @@ packages: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "8.2.5" + version: "8.2.6" permission_handler_platform_interface: dependency: transitive description: @@ -1163,7 +1175,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.2.9" + version: "2.2.10" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b85ceada3..f43a84841 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,22 +1,23 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.4+58 +version: 1.5.5+59 publish_to: none environment: sdk: '>=2.14.0 <3.0.0' +# use `scripts/apply_flavor_{flavor}.sh` to set the right dependencies for the flavor dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter -# TODO TLAD as of 2021/10/18, latest version (v0.11.0) is incompatible with Flutter v2.5 + aves_report: + path: plugins/aves_report + aves_report_platform: + path: plugins/aves_report_crashlytics charts_flutter: - git: - url: git://github.com/google/charts.git - path: charts_flutter collection: connectivity_plus: country_code: @@ -33,8 +34,6 @@ dependencies: git: url: git://github.com/deckerst/fijkplayer.git ref: aves - firebase_core: - firebase_crashlytics: flex_color_picker: fluster: flutter_highlight: @@ -105,8 +104,6 @@ flutter: # language files: # - /lib/l10n/app_{language}.arb # - /android/app/src/main/res/values-{language}/strings.xml -# - /android/app/src/debug/res/values-{language}/strings.xml (optional) -# - /android/app/src/profile/res/values-{language}/strings.xml (optional) # - edit locale name resolution for language setting # generate `AppLocalizations` @@ -116,7 +113,7 @@ flutter: # Test driver # run (any device): -# % flutter drive --flavor universal -t test_driver/driver_app.dart --profile +# % flutter drive --flavor play -t test_driver/driver_play.dart --profile # capture shaders in profile mode (real device only): -# % flutter drive --flavor universal -t test_driver/driver_app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json +# % flutter drive --flavor play -t test_driver/driver_play.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json diff --git a/scripts/apply_flavor_izzy.sh b/scripts/apply_flavor_izzy.sh new file mode 100755 index 000000000..598f41175 --- /dev/null +++ b/scripts/apply_flavor_izzy.sh @@ -0,0 +1,8 @@ +#!/bin/bash +PUBSPEC_PATH="../pubspec.yaml" + +flutter clean + +sed -i 's/aves_report_crashlytics/aves_report_console/g' "$PUBSPEC_PATH" + +flutter pub get diff --git a/scripts/apply_flavor_play.sh b/scripts/apply_flavor_play.sh new file mode 100755 index 000000000..d613cc879 --- /dev/null +++ b/scripts/apply_flavor_play.sh @@ -0,0 +1,8 @@ +#!/bin/bash +PUBSPEC_PATH="../pubspec.yaml" + +flutter clean + +sed -i 's/aves_report_console/aves_report_crashlytics/g' "$PUBSPEC_PATH" + +flutter pub get diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index b7f160fb4..d34d41d9e 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -15,8 +15,10 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future removeIds(Set contentIds, {required bool metadataOnly}) => SynchronousFuture(null); + // entries + @override - Future> loadEntries() => SynchronousFuture({}); + Future> loadAllEntries() => SynchronousFuture({}); @override Future saveEntries(Iterable entries) => SynchronousFuture(null); @@ -24,11 +26,15 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future updateEntryId(int oldId, AvesEntry entry) => SynchronousFuture(null); + // date taken + @override Future> loadDates() => SynchronousFuture({}); + // catalog metadata + @override - Future> loadMetadataEntries() => SynchronousFuture([]); + Future> loadAllMetadataEntries() => SynchronousFuture([]); @override Future saveMetadata(Set metadataEntries) => SynchronousFuture(null); @@ -36,8 +42,10 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future updateMetadataId(int oldId, CatalogMetadata? metadata) => SynchronousFuture(null); + // address + @override - Future> loadAddresses() => SynchronousFuture([]); + Future> loadAllAddresses() => SynchronousFuture([]); @override Future saveAddresses(Set addresses) => SynchronousFuture(null); @@ -45,8 +53,10 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future updateAddressId(int oldId, AddressDetails? address) => SynchronousFuture(null); + // favourites + @override - Future> loadFavourites() => SynchronousFuture({}); + Future> loadAllFavourites() => SynchronousFuture({}); @override Future addFavourites(Iterable rows) => SynchronousFuture(null); @@ -57,8 +67,10 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future removeFavourites(Iterable rows) => SynchronousFuture(null); + // covers + @override - Future> loadCovers() => SynchronousFuture({}); + Future> loadAllCovers() => SynchronousFuture({}); @override Future addCovers(Iterable rows) => SynchronousFuture(null); @@ -68,4 +80,12 @@ class FakeMetadataDb extends Fake implements MetadataDb { @override Future removeCovers(Set filters) => SynchronousFuture(null); + + // video playback + + @override + Future updateVideoPlaybackId(int oldId, int? newId) => SynchronousFuture(null); + + @override + Future removeVideoPlayback(Set contentIds) => SynchronousFuture(null); } diff --git a/test/fake/report_service.dart b/test/fake/report_service.dart index d05e61c8f..c4e8d1701 100644 --- a/test/fake/report_service.dart +++ b/test/fake/report_service.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/report_service.dart'; +import 'package:aves_report/aves_report.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -7,7 +7,7 @@ class FakeReportService extends ReportService { Future init() => SynchronousFuture(null); @override - bool get isCollectionEnabled => false; + Map get state => {}; @override Future setCollectionEnabled(bool enabled) => SynchronousFuture(null); diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 2c8d6c9a7..916df6701 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -17,10 +17,10 @@ import 'package:aves/services/device_service.dart'; import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/services/media/media_store_service.dart'; import 'package:aves/services/metadata/metadata_fetch_service.dart'; -import 'package:aves/services/report_service.dart'; import 'package:aves/services/storage_service.dart'; import 'package:aves/services/window_service.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves_report/aves_report.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; diff --git a/test/model/video/metadata_test.dart b/test/model/video/metadata_test.dart new file mode 100644 index 000000000..ac60cb941 --- /dev/null +++ b/test/model/video/metadata_test.dart @@ -0,0 +1,11 @@ +import 'package:aves/model/video/metadata.dart'; +import 'package:test/test.dart'; + +void main() { + test('Video date parsing', () { + final localOffset = DateTime.now().timeZoneOffset; + + expect(VideoMetadataFormatter.parseVideoDate('2011-05-08T03:46+09:00'), DateTime(2011, 5, 7, 18, 46).add(localOffset).millisecondsSinceEpoch); + expect(VideoMetadataFormatter.parseVideoDate('UTC 2021-05-30 19:14:21'), DateTime(2021, 5, 30, 19, 14, 21).millisecondsSinceEpoch); + }); +} diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 29e242bed..2c3a87eb7 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -1,14 +1,17 @@ +import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/utils/geo_utils.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:latlong2/latlong.dart'; import 'package:test/test.dart'; void main() { test('Decimal degrees to DMS (sexagesimal)', () { - expect(GeoUtils.toDMS(LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam - expect(GeoUtils.toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund - expect(GeoUtils.toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo - expect(GeoUtils.toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio - expect(GeoUtils.toDMS(LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); + final l10n = lookupAppLocalizations(AppLocalizations.supportedLocales.first); + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(37.496667, 127.0275)), ['37° 29′ 48.00″ N', '127° 1′ 39.00″ E']); // Gangnam + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(78.9243503, 11.9230465)), ['78° 55′ 27.66″ N', '11° 55′ 22.97″ E']); // Ny-Ålesund + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(-38.6965891, 175.9830047)), ['38° 41′ 47.72″ S', '175° 58′ 58.82″ E']); // Taupo + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(-64.249391, -56.6556145)), ['64° 14′ 57.81″ S', '56° 39′ 20.21″ W']); // Marambio + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(0, 0)), ['0° 0′ 0.00″ N', '0° 0′ 0.00″ E']); }); test('bounds center', () { diff --git a/test/utils/time_utils_test.dart b/test/utils/time_utils_test.dart index bf911d522..1e5cefb69 100644 --- a/test/utils/time_utils_test.dart +++ b/test/utils/time_utils_test.dart @@ -15,4 +15,17 @@ void main() { expect(DateTime(1903, 9, 25).isAtSameDayAs(DateTime(1970, 2, 25)), false); expect(DateTime(1929, 3, 22).isAtSameDayAs(DateTime(1929, 3, 22)), true); }); + + test('Parse dates', () { + final localOffset = DateTime.now().timeZoneOffset; + + expect(parseUnknownDateFormat('1600995564713'), DateTime(2020, 09, 25, 0, 59, 24, 713).add(localOffset)); + expect(parseUnknownDateFormat('pre1600995564713suf'), DateTime(2020, 09, 25, 0, 59, 24, 713).add(localOffset)); + + expect(parseUnknownDateFormat('1600995564'), DateTime(2020, 09, 25, 0, 59, 24, 0).add(localOffset)); + expect(parseUnknownDateFormat('pre1600995564suf'), DateTime(2020, 09, 25, 0, 59, 24, 0).add(localOffset)); + + expect(parseUnknownDateFormat('IMG_20210901_142523_783'), DateTime(2021, 09, 1, 14, 25, 23, 783)); + expect(parseUnknownDateFormat('Screenshot_20211028-115056_Aves'), DateTime(2021, 10, 28, 11, 50, 56, 0)); + }); } diff --git a/test_driver/driver_app.dart b/test_driver/driver_play.dart similarity index 93% rename from test_driver/driver_app.dart rename to test_driver/driver_play.dart index 735978f0c..18db5c2f8 100644 --- a/test_driver/driver_app.dart +++ b/test_driver/driver_play.dart @@ -1,6 +1,6 @@ import 'dart:ui'; -import 'package:aves/main.dart' as app; +import 'package:aves/main_play.dart' as app; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/media/media_store_service.dart'; @@ -30,7 +30,7 @@ Future configureAndLaunch() async { settings ..keepScreenOn = KeepScreenOn.always ..hasAcceptedTerms = false - ..isErrorReportingEnabled = false + ..isErrorReportingAllowed = false ..locale = const Locale('en') ..homePage = HomePageSetting.collection ..imageBackground = EntryBackground.checkered; diff --git a/test_driver/driver_app_test.dart b/test_driver/driver_play_test.dart similarity index 99% rename from test_driver/driver_app_test.dart rename to test_driver/driver_play_test.dart index 13725a133..5fc57364a 100644 --- a/test_driver/driver_app_test.dart +++ b/test_driver/driver_play_test.dart @@ -169,7 +169,7 @@ void selectFirstAlbum() { void searchAlbum() { test('[collection] search album', () async { - await driver.tap(find.byValueKey('search-button')); + await driver.tap(find.byValueKey('menu-search')); await driver.waitUntilNoTransientCallbacks(); const albumPath = targetPicturesDirEmulated; diff --git a/untranslated.json b/untranslated.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/untranslated.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 039c9cfcf..7f651acd6 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ -Thanks for using Aves! In v1.5.4: -- modify files in the Download folder on Android 11 -- choose to rename, replace or skip when moving items with name conflict -- show images for a specific region from the Map page -- scanning many items is now happening in a service +Thanks for using Aves! In v1.5.5: +- modify items in bulk (rotation, date, metadata removal) +- filter items by title +- enjoy the app in Russian +Note: the video thumbnails are modified. Clearing the app cache may be necessary. Full changelog available on GitHub \ No newline at end of file