diff --git a/CHANGELOG.md b/CHANGELOG.md index b63d076f8..5c904f43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.5.7] - 2021-12-01 + +### Added + +- add and remove tags to JPEG/GIF/PNG/TIFF images +- French translation +- support for Android KitKat (without Google Maps) +- Viewer: maximum brightness option + +### Changed + +- Settings: select hidden path directory with a custom file picker instead of the native SAF one +- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed + +### Fixed + +- pinch-to-zoom gesture on thumbnails was difficult to trigger +- double-tap gesture in the viewer was ignored in some cases +- copied items had the wrong date + ## [v1.5.6] - 2021-11-12 ### Added diff --git a/README.md b/README.md index e86f34117..43e10ab02 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka **Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc. -Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**. +Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**. ## Screenshots @@ -55,7 +55,7 @@ At this stage this project does *not* accept PRs, except for translations. ### 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. +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 are already handled. ### Donations @@ -82,5 +82,10 @@ To run the app: # flutter run -t lib/main_play.dart --flavor play ``` +To run the app on API 19 emulators: +``` +# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering +``` + [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 bccdc8a54..077647e2e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -55,9 +55,8 @@ android { applicationId appId // minSdkVersion constraints: // - Flutter & other plugins: 16 - // - google_maps_flutter v2.0.5: 20 - // - Aves native: 19 - minSdkVersion 20 + // - google_maps_flutter v2.1.1: 20 + minSdkVersion 19 targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -149,7 +148,7 @@ dependencies { // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android - implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' + implementation 'com.github.deckerst:pixymeta-android:706bd73d6e' implementation 'com.github.bumptech.glide:glide:4.12.0' kapt 'androidx.annotation:annotation:1.3.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0546ae2a0..90111d4fd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,21 +4,12 @@ android:installLocation="auto"> - + + + 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 d4cfcb806..409f361bd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt @@ -23,7 +23,6 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.runBlocking -import java.util.* class AnalysisService : MethodChannel.MethodCallHandler, Service() { private var backgroundFlutterEngine: FlutterEngine? = null @@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger // channels for analysis - MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) + MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) @@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() { getString(R.string.analysis_notification_action_stop), stopServiceIntent ).build() + val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round return NotificationCompat.Builder(this, CHANNEL_ANALYSIS) .setContentTitle(title ?: getText(R.string.analysis_notification_default_title)) .setContentText(message) .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) - .setSmallIcon(R.drawable.ic_notification) + .setSmallIcon(icon) .setContentIntent(openAppIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .addAction(stopAction) 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 64cba6a9c..d13ddcaaa 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) - MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) + MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) @@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() { DELETE_SINGLE_PERMISSION_REQUEST, MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) CREATE_FILE_REQUEST, - OPEN_FILE_REQUEST, - SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data) + OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data) } } @@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() { return } - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + } // resume pending action onStorageAccessResult(requestCode, treeUri) @@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() { private fun extractIntentData(intent: Intent?): MutableMap { when (intent?.action) { Intent.ACTION_MAIN -> { - intent.getStringExtra("page")?.let { page -> - var filters = intent.getStringArrayExtra("filters")?.toList() + intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page -> + var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList() if (filters == null) { // fallback for shortcuts created on API < 26 - val filterString = intent.getStringExtra("filtersString") + val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING) if (filterString != null) { filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR) } } return hashMapOf( - "page" to page, - "filters" to filters, + INTENT_DATA_KEY_PAGE to page, + INTENT_DATA_KEY_FILTERS to filters, ) } } Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> { (intent.data ?: (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri))?.let { uri -> return hashMapOf( - "action" to "view", - "uri" to uri.toString(), - "mimeType" to intent.type, // MIME type is optional + INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW, + INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional + INTENT_DATA_KEY_URI to uri.toString(), ) } } Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> { return hashMapOf( - "action" to "pick", - "mimeType" to intent.type, + INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK, + INTENT_DATA_KEY_MIME_TYPE to intent.type, ) } Intent.ACTION_SEARCH -> { val viewUri = intent.dataString return if (viewUri != null) hashMapOf( - "action" to "view", - "uri" to viewUri, - "mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY), + INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW, + INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY), + INTENT_DATA_KEY_URI to viewUri, ) else hashMapOf( - "action" to "search", - "query" to intent.getStringExtra(SearchManager.QUERY), + INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH, + INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY), ) } Intent.ACTION_RUN -> { @@ -261,7 +262,7 @@ class MainActivity : FlutterActivity() { .setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search)) .setIntent( Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) - .putExtra("page", "/search") + .putExtra(SHORTCUT_KEY_PAGE, "/search") ) .build() @@ -270,7 +271,7 @@ class MainActivity : FlutterActivity() { .setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie)) .setIntent( Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) - .putExtra("page", "/collection") + .putExtra(SHORTCUT_KEY_PAGE, "/collection") .putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")) ) .build() @@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() { const val OPEN_FROM_ANALYSIS_SERVICE = 2 const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 - const val SELECT_DIRECTORY_REQUEST = 5 - const val DELETE_SINGLE_PERMISSION_REQUEST = 6 - const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7 + const val DELETE_SINGLE_PERMISSION_REQUEST = 5 + const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6 + + const val INTENT_DATA_KEY_ACTION = "action" + const val INTENT_DATA_KEY_FILTERS = "filters" + const val INTENT_DATA_KEY_MIME_TYPE = "mimeType" + const val INTENT_DATA_KEY_PAGE = "page" + const val INTENT_DATA_KEY_URI = "uri" + const val INTENT_DATA_KEY_QUERY = "query" + + const val INTENT_ACTION_PICK = "pick" + const val INTENT_ACTION_SEARCH = "search" + const val INTENT_ACTION_VIEW = "view" + + const val SHORTCUT_KEY_PAGE = "page" + const val SHORTCUT_KEY_FILTERS_ARRAY = "filters" + const val SHORTCUT_KEY_FILTERS_STRING = "filtersString" // request code to pending runnable val pendingStorageAccessResultHandlers = ConcurrentHashMap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt index 77b5e015b..b18950cf0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt @@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler { private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var removed = false - try { - removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get settings", e) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + try { + removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null) + } } result.success(removed) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt index 8f0a263b5..b0d144906 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt @@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp } // can be null or empty - val contentIds = call.argument>("contentIds"); + val contentIds = call.argument>("contentIds") if (!activity.isMyServiceRunning(AnalysisService::class.java)) { val intent = Intent(activity, AnalysisService::class.java) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 2d8dfe057..df244191f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -18,6 +18,10 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.request.RequestOptions import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR +import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY +import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING +import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE import deckers.thibault.aves.R import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend @@ -47,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { "openMap" -> safe(call, result, ::openMap) "setAs" -> safe(call, result, ::setAs) "share" -> safe(call, result, ::share) - "canPin" -> safe(call, result, ::canPin) - "pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) } + "pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) } else -> result.notImplemented() } } @@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { fun addPackageDetails(intent: Intent) { // apps tend to use their name in English when creating directories // so we get their names in English as well as the current locale - val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) } + val englishConfig = Configuration().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + setLocale(Locale.ENGLISH) + } else { + @Suppress("deprecation") + locale = Locale.ENGLISH + } + } val pm = context.packageManager for (resolveInfo in pm.queryIntentActivities(intent, 0)) { @@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // shortcuts - private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) - - private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { - result.success(isPinSupported()) - } - - private fun pin(call: MethodCall, result: MethodChannel.Result) { + private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) { val label = call.argument("label") val iconBytes = call.argument("iconBytes") val filters = call.argument>("filters") @@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return } - if (!isPinSupported()) { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null) return } @@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = when { uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java) filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) - .putExtra("page", "/collection") - .putExtra("filters", filters.toTypedArray()) + .putExtra(SHORTCUT_KEY_PAGE, "/collection") + .putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray()) // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut // so we use a joined `String` as fallback - .putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR)) + .putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR)) else -> { result.error("pin-intent", "failed to build intent", null) return diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index 4240743d0..a4dcd071f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -1,21 +1,42 @@ package deckers.thibault.aves.channel.calls +import android.content.Context import android.os.Build +import androidx.core.content.pm.ShortcutManagerCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import java.util.* -class DeviceHandler : MethodCallHandler { +class DeviceHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { + "getCapabilities" -> safe(call, result, ::getCapabilities) "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) else -> result.notImplemented() } } + private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + val sdkInt = Build.VERSION.SDK_INT + result.success( + hashMapOf( + "canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP), + "canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context), + "canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT), + "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP), + // as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage, + // but using hybrid composition would make it usable on API 19 too, + // cf https://github.com/flutter/flutter/issues/23728 + "canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH), + "hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT), + "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), + ) + ) + } + private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(TimeZone.getDefault().id) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt index 6ff4ef4b4..9040083d5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt @@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } + "setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) } + "setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) } else -> result.notImplemented() } @@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler { }) } + private fun setIptc(call: MethodCall, result: MethodChannel.Result) { + val iptc = call.argument>("iptc") + val entryMap = call.argument("entry") + val postEditScan = call.argument("postEditScan") + if (entryMap == null || postEditScan == null) { + result.error("setIptc-args", "failed because of missing arguments", null) + return + } + + val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } + val path = entryMap["path"] as String? + val mimeType = entryMap["mimeType"] as String? + if (uri == null || path == null || mimeType == null) { + result.error("setIptc-args", "failed because entry fields are missing", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("setIptc-provider", "failed to find provider for uri=$uri", null) + return + } + + provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message) + }) + } + + private fun setXmp(call: MethodCall, result: MethodChannel.Result) { + val xmp = call.argument("xmp") + val extendedXmp = call.argument("extendedXmp") + val entryMap = call.argument("entry") + if (entryMap == null) { + result.error("setXmp-args", "failed because of missing arguments", null) + return + } + + val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } + val path = entryMap["path"] as String? + val mimeType = entryMap["mimeType"] as String? + if (uri == null || path == null || mimeType == null) { + result.error("setXmp-args", "failed because entry fields are missing", null) + return + } + + val provider = getProvider(uri) + if (provider == null) { + result.error("setXmp-provider", "failed to find provider for uri=$uri", null) + return + } + + provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback { + override fun onSuccess(fields: FieldMap) = result.success(fields) + override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message) + }) + } + private fun removeTypes(call: MethodCall, result: MethodChannel.Result) { val types = call.argument>("types") val entryMap = call.argument("entry") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index 3fbbb707f..c1e4c2ae4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -10,6 +10,8 @@ import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException +import com.adobe.internal.xmp.XMPMetaFactory +import com.adobe.internal.xmp.options.SerializeOptions import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.drew.imaging.ImageMetadataReader import com.drew.lang.KeyValuePair @@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.text.ParseException import java.util.* @@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } + "getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) } + "getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } else -> result.notImplemented() @@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { val kv = pair as KeyValuePair val key = kv.key // `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 - val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset + val charset = if (baseDirName == PNG_ITXT_DIR_NAME) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + StandardCharsets.UTF_8 + } else { + Charset.forName("UTF-8") + } + } else { + kv.value.charset + } val valueString = String(kv.value.bytes, charset) val dirs = extractPngProfile(key, valueString) if (dirs?.any() == true) { @@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int try { - retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it } + } if (!metadataMap.containsKey(KEY_DATE_MILLIS)) { retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } } @@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { return } - val saveExposureTime: (value: Rational) -> Unit = { + val saveExposureTime = fun(value: Rational) { // `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal // so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000) // and process it to make sure the numerator is `1` when the ratio value is less than 1 - val num = it.numerator - val denom = it.denominator + val num = value.numerator + val denom = value.denominator metadataMap[KEY_EXPOSURE_TIME] = when { - num >= denom -> "${it.toSimpleString(true)}″" + num >= denom -> "${value.toSimpleString(true)}″" num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString() - else -> it.toString() + else -> value.toString() } } @@ -734,6 +749,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null) } + private fun getIptc(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + if (mimeType == null || uri == null) { + result.error("getIptc-args", "failed because of missing arguments", null) + return + } + + if (MimeTypes.canReadWithPixyMeta(mimeType)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val iptcDataList = PixyMetaHelper.getIptc(input) + result.success(iptcDataList) + return + } + } catch (e: Exception) { + result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message) + return + } + } + + result.success(null) + } + + private fun getXmp(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + if (mimeType == null || uri == null) { + result.error("getXmp-args", "failed because of missing arguments", null) + return + } + + if (canReadWithMetadataExtractor(mimeType)) { + try { + Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input) + val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) } + result.success(xmpStrings.toMutableList()) + return + } + } catch (e: Exception) { + result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message) + return + } catch (e: NoClassDefFoundError) { + result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message) + return + } + } + + result.success(null) + } + private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) { val prop = call.argument("prop") if (prop == null) { @@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { "XMP", ) + private val xmpSerializeOptions = SerializeOptions().apply { + omitPacketWrapper = true // e.g. ... + omitXmpMetaElement = false // e.g. ... + } + // catalog metadata private const val KEY_MIME_TYPE = "mimeType" private const val KEY_DATE_MILLIS = "dateMillis" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt index 2494977f9..528e70941 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt @@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { try { locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0 } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get settings", e) + Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null) } result.success(locked) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt index 925dd90de..a4796a3c6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.graphics.BitmapRegionDecoder import android.graphics.Rect import android.net.Uri +import android.os.Build import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -68,7 +69,12 @@ class RegionFetcher internal constructor( if (currentDecoderRef == null) { val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input -> @Suppress("BlockingMethodInNonBlockingContext") - BitmapRegionDecoder.newInstance(input, false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(input) + } else { + @Suppress("deprecation") + BitmapRegionDecoder.newInstance(input, false) + } } if (newDecoder == null) { result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt index 26c15805a..4b53b693c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams import android.content.Context import android.database.ContentObserver import android.net.Uri +import android.os.Build import android.os.Handler import android.os.Looper import android.provider.Settings @@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S override fun onChange(selfChange: Boolean, uri: Uri?) { if (update()) { - success( - hashMapOf( - Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation, - Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale, - ) + val settings: FieldMap = hashMapOf( + Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation, ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale + } + success(settings) } } @@ -49,14 +51,15 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S accelerometerRotation = newAccelerometerRotation changed = true } - val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) - if (transitionAnimationScale != newTransitionAnimationScale) { - transitionAnimationScale = newTransitionAnimationScale - changed = true + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) + if (transitionAnimationScale != newTransitionAnimationScale) { + transitionAnimationScale = newTransitionAnimationScale + changed = true + } } - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get settings", e) + Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null) } return changed } 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 6586c5df8..35c564e73 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 @@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager -import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers @@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } - "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } else -> endOfStream() } } @@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? } private fun createFile() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + // TODO TLAD [<=API18] create file + error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null) + return + } + val name = args["name"] as String? val mimeType = args["mimeType"] as String? val bytes = args["bytes"] as ByteArray? @@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? private fun openFile() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + // TODO TLAD [<=API18] open file + error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null) + return + } + val mimeType = args["mimeType"] as String? if (mimeType == null) { error("openFile-args", "failed because of missing arguments", null) @@ -158,24 +168,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } - private fun selectDirectory() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - - MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri -> - success(StorageUtils.convertTreeUriToDirPath(activity, uri)) - endOfStream() - }, { - success(null) - endOfStream() - }) - activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST) - } else { - success(null) - endOfStream() - } - } - override fun onCancel(arguments: Any?) {} private fun success(result: Any?) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt index c0bd10e44..4a7a048f6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt @@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I val bitmapHeight: Int if (width / height > svgWidth / svgHeight) { bitmapWidth = ceil(svgWidth * height / svgHeight).toInt() - bitmapHeight = height; + bitmapHeight = height } else { bitmapWidth = width bitmapHeight = ceil(svgHeight * width / svgWidth).toInt() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index 3817c7b38..102387552 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -223,7 +223,9 @@ object ExifInterfaceHelper { val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap() // exclude Exif directory when it only includes image size - val isUselessExif: (Map) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") } + val isUselessExif = fun(it: Map): Boolean { + return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") + } return HashMap>().apply { put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf()) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt index 1d04c0de9..4b2f04d16 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt @@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper { MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks", MediaMetadataRetriever.METADATA_KEY_TITLE to "Title", MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height", - MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation", MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width", MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer", MediaMetadataRetriever.METADATA_KEY_YEAR to "Year", ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation") + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate") } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt index 31d6adf4c..8e86a124e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt @@ -32,12 +32,12 @@ object Metadata { const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom // types of metadata + const val TYPE_COMMENT = "comment" const val TYPE_EXIF = "exif" const val TYPE_ICC_PROFILE = "icc_profile" const val TYPE_IPTC = "iptc" const val TYPE_JFIF = "jfif" const val TYPE_JPEG_ADOBE = "jpeg_adobe" - const val TYPE_JPEG_COMMENT = "jpeg_comment" const val TYPE_JPEG_DUCKY = "jpeg_ducky" const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" const val TYPE_XMP = "xmp" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt index 588927a9a..3cfa5f1bd 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt @@ -54,9 +54,11 @@ object MultiPage { // do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks // e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1 - format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 } format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt index fb4df2a92..eee140f28 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt @@ -1,17 +1,21 @@ package deckers.thibault.aves.metadata +import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE -import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB import deckers.thibault.aves.metadata.Metadata.TYPE_XMP +import deckers.thibault.aves.model.FieldMap import pixy.meta.meta.Metadata import pixy.meta.meta.MetadataEntry import pixy.meta.meta.MetadataType +import pixy.meta.meta.iptc.IPTC +import pixy.meta.meta.iptc.IPTCDataSet +import pixy.meta.meta.iptc.IPTCRecord import pixy.meta.meta.jpeg.JPGMeta import pixy.meta.meta.xmp.XMP import pixy.meta.string.XMLUtils @@ -50,9 +54,46 @@ object PixyMetaHelper { return metadataMap } + fun getIptc(input: InputStream): List? { + val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null + + val iptcDataList = ArrayList() + iptc.dataSets.forEach { dataSetEntry -> + val tag = dataSetEntry.key + val dataSets = dataSetEntry.value + iptcDataList.add( + hashMapOf( + "record" to tag.recordNumber, + "tag" to tag.tag, + "values" to dataSets.map { it.data }.toMutableList(), + ) + ) + } + return iptcDataList + } + + fun setIptc( + input: InputStream, + output: OutputStream, + iptcDataList: List?, + ) { + val iptc = iptcDataList?.flatMap { + val record = it["record"] as Int + val tag = it["tag"] as Int + val values = it["values"] as List<*> + values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) } + } ?: ArrayList() + Metadata.insertIPTC(input, output, iptc) + } + fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP? - fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) { + fun setXmp( + input: InputStream, + output: OutputStream, + xmpString: String?, + extendedXmpString: String? + ) { if (extendedXmpString != null) { JPGMeta.insertXMP(input, output, xmpString, extendedXmpString) } else { @@ -70,12 +111,12 @@ object PixyMetaHelper { } private fun toMetadataType(typeString: String): MetadataType? = when (typeString) { + TYPE_COMMENT -> MetadataType.COMMENT TYPE_EXIF -> MetadataType.EXIF TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE TYPE_IPTC -> MetadataType.IPTC TYPE_JFIF -> MetadataType.JPG_JFIF TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE - TYPE_JPEG_COMMENT -> MetadataType.COMMENT TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB TYPE_XMP -> MetadataType.XMP diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index e649bfacf..72c38f447 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -23,7 +23,7 @@ object XMP { private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/" private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" - const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" + private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index 924c76a1d..cbba47781 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -5,6 +5,7 @@ import android.content.Context import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri +import android.os.Build import androidx.exifinterface.media.ExifInterface import com.drew.imaging.ImageMetadataReader import com.drew.metadata.avi.AviDirectory @@ -135,10 +136,12 @@ class SourceEntry { try { retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it } retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it } - retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it } retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it } retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it } + } } catch (e: Exception) { // ignore } finally { 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 e70aa3437..e74547232 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 @@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.MimeTypes.canEditExif +import deckers.thibault.aves.utils.MimeTypes.canEditIptc import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor @@ -460,6 +461,94 @@ abstract class ImageProvider { return true } + private fun editIptc( + context: Context, + path: String, + uri: Uri, + mimeType: String, + callback: ImageOpCallback, + trailerDiff: Int = 0, + iptc: List?, + ): Boolean { + if (!canEditIptc(mimeType)) { + callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) + return false + } + + val originalFileSize = File(path).length() + val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } + var videoBytes: ByteArray? = null + val editableFile = File.createTempFile("aves", null).apply { + deleteOnExit() + try { + outputStream().use { output -> + if (videoSize != null) { + // handle motion photo and embedded video separately + val imageSize = (originalFileSize - videoSize).toInt() + videoBytes = ByteArray(videoSize) + + StorageUtils.openInputStream(context, uri)?.let { input -> + val imageBytes = ByteArray(imageSize) + input.read(imageBytes, 0, imageSize) + input.read(videoBytes, 0, videoSize) + + // copy only the image to a temporary file for editing + // video will be appended after metadata modification + ByteArrayInputStream(imageBytes).use { imageInput -> + imageInput.copyTo(output) + } + } + } else { + // copy original file to a temporary file for editing + StorageUtils.openInputStream(context, uri)?.use { imageInput -> + imageInput.copyTo(output) + } + } + } + } catch (e: Exception) { + callback.onFailure(e) + return false + } + } + + try { + editableFile.outputStream().use { output -> + // reopen input to read from start + StorageUtils.openInputStream(context, uri)?.use { input -> + when { + iptc != null -> + PixyMetaHelper.setIptc(input, output, iptc) + canRemoveMetadata(mimeType) -> + PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC)) + else -> { + Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType") + PixyMetaHelper.setIptc(input, output, null) + } + } + } + } + + if (videoBytes != null) { + // append trailer video, if any + editableFile.appendBytes(videoBytes!!) + } + + // copy the edited temporary file back to the original + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) + + if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { + return false + } + } catch (e: IOException) { + callback.onFailure(e) + return false + } + + return true + } + + // provide `editCoreXmp` to modify existing core XMP, + // or provide `coreXmp` and `extendedXmp` to set them private fun editXmp( context: Context, path: String, @@ -467,7 +556,9 @@ abstract class ImageProvider { mimeType: String, callback: ImageOpCallback, trailerDiff: Int = 0, - edit: (xmp: String) -> String, + coreXmp: String? = null, + extendedXmp: String? = null, + editCoreXmp: ((xmp: String) -> String)? = null, ): Boolean { if (!canEditXmp(mimeType)) { callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) @@ -479,18 +570,34 @@ abstract class ImageProvider { val editableFile = File.createTempFile("aves", null).apply { deleteOnExit() try { - val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } - if (xmp == null) { - callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) - return false + var editedXmpString = coreXmp + var editedExtendedXmp = extendedXmp + if (editCoreXmp != null) { + val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } + if (pixyXmp != null) { + editedXmpString = editCoreXmp(pixyXmp.xmpDocString()) + if (pixyXmp.hasExtendedXmp()) { + editedExtendedXmp = pixyXmp.extendedXmpDocString() + } + } } outputStream().use { output -> // reopen input to read from start StorageUtils.openInputStream(context, uri)?.use { input -> - val editedXmpString = edit(xmp.xmpDocString()) - val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null - PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) + if (editedXmpString != null) { + if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) { + Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType") + PixyMetaHelper.setXmp(input, output, editedXmpString, null) + } else { + PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp) + } + } else if (canRemoveMetadata(mimeType)) { + PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP)) + } else { + Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType") + PixyMetaHelper.setXmp(input, output, null, null) + } } } } catch (e: Exception) { @@ -538,7 +645,7 @@ abstract class ImageProvider { "We need to edit XMP to adjust trailer video offset by $diff bytes." ) val newTrailerOffset = trailerOffset + diff - return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp -> + return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp -> xmp.replace( // GCamera motion photo "${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"", @@ -548,7 +655,7 @@ abstract class ImageProvider { "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"", "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", ) - } + }) } fun editOrientation( @@ -679,6 +786,65 @@ abstract class ImageProvider { } } + fun setIptc( + context: Context, + path: String, + uri: Uri, + mimeType: String, + postEditScan: Boolean, + callback: ImageOpCallback, + iptc: List? = null, + ) { + val newFields = HashMap() + + val success = editIptc( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + iptc = iptc, + ) + + if (success) { + if (postEditScan) { + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) + } else { + callback.onSuccess(HashMap()) + } + } else { + callback.onFailure(Exception("failed to set IPTC")) + } + } + + fun setXmp( + context: Context, + path: String, + uri: Uri, + mimeType: String, + callback: ImageOpCallback, + coreXmp: String? = null, + extendedXmp: String? = null, + ) { + val newFields = HashMap() + + val success = editXmp( + context = context, + path = path, + uri = uri, + mimeType = mimeType, + callback = callback, + coreXmp = coreXmp, + extendedXmp = extendedXmp, + ) + + if (success) { + scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) + } else { + callback.onFailure(Exception("failed to set XMP")) + } + } + fun removeMetadataTypes( context: Context, path: String, 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 aa7d090e9..be6731028 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 @@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() { fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) } + // the provided URI can point to the wrong media collection, + // e.g. a GIF image with the URI `content://media/external/video/media/[ID]` + // so the effective entry URI may not match the provided URI override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { var found = false val fetched = arrayListOf() val id = uri.tryParseId() - val onSuccess = fun(entry: FieldMap) { - entry["uri"] = uri.toString() - fetched.add(entry) - } - val alwaysValid = { _: Int, _: Int -> true } + val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true + val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) } if (id != null) { if (!found && (sourceMimeType == null || isImage(sourceMimeType))) { val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id) @@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() { } fun checkObsoleteContentIds(context: Context, knownContentIds: List): List { - val foundContentIds = ArrayList() + val foundContentIds = HashSet() fun check(context: Context, contentUri: Uri) { val projection = arrayOf(MediaStore.MediaColumns._ID) try { 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 973743105..afcf70c6e 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 @@ -23,7 +23,7 @@ object FlutterUtils { } lateinit var flutterLoader: FlutterLoader - FlutterUtils.runOnUiThread { + runOnUiThread { // initialization must happen on the main thread flutterLoader = FlutterInjector.instance().flutterLoader().apply { startInitialization(context) 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 6416286b2..5e686fa9f 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 @@ -110,7 +110,16 @@ object MimeTypes { } // as of latest PixyMeta - fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType) + fun canEditIptc(mimeType: String) = when (mimeType) { + JPEG, TIFF -> true + else -> false + } + + // as of latest PixyMeta + fun canEditXmp(mimeType: String) = when (mimeType) { + JPEG, TIFF, PNG, GIF -> true + else -> false + } // as of latest PixyMeta fun canRemoveMetadata(mimeType: String) = when (mimeType) { 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 32c40d1da..a078ea9a1 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 @@ -142,39 +142,6 @@ object PermissionManager { } } - fun getRestrictedDirectories(context: Context): List> { - val dirs = ArrayList>() - val sdkInt = Build.VERSION.SDK_INT - - if (sdkInt >= Build.VERSION_CODES.R) { - // cf https://developer.android.com/about/versions/11/privacy/storage#directory-access - val volumePaths = StorageUtils.getVolumePaths(context) - dirs.addAll(volumePaths.map { - hashMapOf( - "volumePath" to it, - "relativeDir" to "", - ) - }) - dirs.addAll(volumePaths.map { - hashMapOf( - "volumePath" to it, - "relativeDir" to Environment.DIRECTORY_DOWNLOADS, - ) - }) - } else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) { - // no SD card volume access on KitKat - val primaryVolume = StorageUtils.getPrimaryVolumePath(context) - val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume } - dirs.addAll(nonPrimaryVolumes.map { - hashMapOf( - "volumePath" to it, - "relativeDir" to "", - ) - }) - } - return dirs - } - fun canInsertByMediaStore(directories: List): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { directories.all { @@ -195,12 +162,14 @@ object PermissionManager { } ?: false } - // returns paths matching URIs granted by the user + // returns paths matching directory URIs granted by the user fun getGrantedDirs(context: Context): Set { val grantedDirs = HashSet() - for (uriPermission in context.contentResolver.persistedUriPermissions) { - val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri) - dirPath?.let { grantedDirs.add(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + for (uriPermission in context.contentResolver.persistedUriPermissions) { + val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri) + dirPath?.let { grantedDirs.add(it) } + } } return grantedDirs } @@ -208,13 +177,53 @@ object PermissionManager { // returns paths accessible to the app (granted by the user or by default) private fun getAccessibleDirs(context: Context): Set { val accessibleDirs = HashSet(getGrantedDirs(context)) - // from Android R, we no longer have access permission by default on primary volume - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + + // until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage + // from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level + // from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible + // from API 30 / Android 11 / R, any storage requires access permission + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) { + accessibleDirs.addAll(StorageUtils.getVolumePaths(context)) + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context)) } return accessibleDirs } + fun getRestrictedDirectories(context: Context): List> { + val dirs = ArrayList>() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // cf https://developer.android.com/about/versions/11/privacy/storage#directory-access + val volumePaths = StorageUtils.getVolumePaths(context) + dirs.addAll(volumePaths.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to "", + ) + }) + dirs.addAll(volumePaths.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to Environment.DIRECTORY_DOWNLOADS, + ) + }) + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT + || Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) { + // removable storage requires access permission, at the file level + // without directory access, we consider the whole volume restricted + val primaryVolume = StorageUtils.getPrimaryVolumePath(context) + val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume } + dirs.addAll(nonPrimaryVolumes.map { + hashMapOf( + "volumePath" to it, + "relativeDir" to "", + ) + }) + } + return dirs + } + // As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted // URI permissions we hold points to a folder that no longer exists, // so we should remove these obsolete URIs before proceeding. @@ -234,6 +243,7 @@ object PermissionManager { } } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) private fun releaseUriPermission(context: Context, it: Uri) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.releasePersistableUriPermission(it, flags) diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 000000000..46f2079d0 --- /dev/null +++ b/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,10 @@ + + + Aves + Recherche + Vidéos + Analyse des images + Analyse des images & vidéos + Analyse des images + Annuler + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index a66954e85..ceb368710 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.5.31' + ext.kotlin_version = '1.6.0' repositories { google() mavenCentral() diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 15d681520..8e7929a6f 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -2,4 +2,4 @@ Navigation und Suche ist ein wichtiger Bestandteil von Aves. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können. -Aves lässt sich mit Android (von API 20 bis 31, d. h. von Lollipop bis S) mit Funktionen wie App-Verknüpfungen und globaler Suche integrieren. Es funktioniert auch als Medienbetrachter und -auswahl. \ No newline at end of file +Aves lässt sich mit Android (von API 19 bis 31, d. h. von KitKat bis S) mit Funktionen wie App-Verknüpfungen und globaler Suche integrieren. Es funktioniert auch als Medienbetrachter und -auswahl. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1060.txt b/fastlane/metadata/android/en-US/changelogs/1060.txt deleted file mode 100644 index 257fd5ad9..000000000 --- a/fastlane/metadata/android/en-US/changelogs/1060.txt +++ /dev/null @@ -1,10 +0,0 @@ -In v1.5.6: -- fixed video playback ignoring hardware-accelerated codecs on recent devices -- partially fixed deleted files leaving ghost items on some devices -- you can now create shortcuts to a specific media item, not only collections -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 diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index c7ccffdc9..8a74c8d0b 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -2,4 +2,4 @@ Navigation and search is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc. -Aves integrates with Android (from API 20 to 31, i.e. from Lollipop to S) with features such as app shortcuts and global search handling. It also works as a media viewer and picker. \ No newline at end of file +Aves integrates with Android (from API 19 to 31, i.e. from KitKat to S) with features such as app shortcuts and global search handling. It also works as a media viewer and picker. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png index 73c4a32c9..3b7a936cc 100644 Binary files a/fastlane/metadata/android/en-US/images/icon.png and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd0091b18..8ac9fddae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -53,6 +53,8 @@ "@hideTooltip": {}, "removeTooltip": "Remove", "@removeTooltip": {}, + "resetButtonTooltip": "Reset", + "@resetButtonTooltip": {}, "doubleBackExitMessage": "Tap “back” again to exit.", "@doubleBackExitMessage": {}, @@ -145,6 +147,8 @@ "entryInfoActionEditDate": "Edit date & time", "@entryInfoActionEditDate": {}, + "entryInfoActionEditTags": "Edit tags", + "@entryInfoActionEditTags": {}, "entryInfoActionRemoveMetadata": "Remove metadata", "@entryInfoActionRemoveMetadata": {}, @@ -417,7 +421,7 @@ "removeEntryMetadataDialogMore": "More", "@removeEntryMetadataDialogMore": {}, - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?", "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {}, "videoSpeedDialogLabel": "Playback speed", @@ -501,6 +505,17 @@ "@aboutCreditsWorldAtlas2": {}, "aboutCreditsTranslators": "Translators:", "@aboutCreditsTranslators": {}, + "aboutCreditsTranslatorLine": "{language}: {names}", + "@aboutCreditsTranslatorLine": { + "placeholders": { + "language": { + "type": "String" + }, + "names": { + "type": "String" + } + } + }, "aboutLicenses": "Open-Source Licenses", "@aboutLicenses": {}, @@ -646,6 +661,8 @@ "@drawerCollectionImages": {}, "drawerCollectionVideos": "Videos", "@drawerCollectionVideos": {}, + "drawerCollectionAnimated": "Animated", + "@drawerCollectionAnimated": {}, "drawerCollectionMotionPhotos": "Motion photos", "@drawerCollectionMotionPhotos": {}, "drawerCollectionPanoramas": "Panoramas", @@ -791,20 +808,10 @@ "settingsSectionViewer": "Viewer", "@settingsSectionViewer": {}, - "settingsViewerShowOverlayOnOpening": "Show overlay on opening", - "@settingsViewerShowOverlayOnOpening": {}, - "settingsViewerShowMinimap": "Show minimap", - "@settingsViewerShowMinimap": {}, - "settingsViewerShowInformation": "Show information", - "@settingsViewerShowInformation": {}, - "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", - "@settingsViewerShowInformationSubtitle": {}, - "settingsViewerShowShootingDetails": "Show shooting details", - "@settingsViewerShowShootingDetails": {}, - "settingsViewerEnableOverlayBlurEffect": "Overlay blur effect", - "@settingsViewerEnableOverlayBlurEffect": {}, "settingsViewerUseCutout": "Use cutout area", "@settingsViewerUseCutout": {}, + "settingsViewerMaximumBrightness": "Maximum brightness", + "@settingsViewerMaximumBrightness": {}, "settingsImageBackground": "Image background", "@settingsImageBackground": {}, @@ -821,6 +828,23 @@ "settingsViewerQuickActionEmpty": "No buttons", "@settingsViewerQuickActionEmpty": {}, + "settingsViewerOverlayTile": "Overlay", + "@settingsViewerOverlayTile": {}, + "settingsViewerOverlayTitle": "Overlay", + "@settingsViewerOverlayTitle": {}, + "settingsViewerShowOverlayOnOpening": "Show on opening", + "@settingsViewerShowOverlayOnOpening": {}, + "settingsViewerShowMinimap": "Show minimap", + "@settingsViewerShowMinimap": {}, + "settingsViewerShowInformation": "Show information", + "@settingsViewerShowInformation": {}, + "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", + "@settingsViewerShowInformationSubtitle": {}, + "settingsViewerShowShootingDetails": "Show shooting details", + "@settingsViewerShowShootingDetails": {}, + "settingsViewerEnableOverlayBlurEffect": "Blur effect", + "@settingsViewerEnableOverlayBlurEffect": {}, + "settingsVideoPageTitle": "Video Settings", "@settingsVideoPageTitle": {}, "settingsSectionVideo": "Video", @@ -880,8 +904,11 @@ "settingsSaveSearchHistory": "Save search history", "@settingsSaveSearchHistory": {}, - "settingsHiddenFiltersTile": "Hidden filters", - "@settingsHiddenFiltersTile": {}, + "settingsHiddenItemsTile": "Hidden items", + "@settingsHiddenItemsTile": {}, + "settingsHiddenItemsTitle": "Hidden Items", + "@settingsHiddenItemsTitle": {}, + "settingsHiddenFiltersTitle": "Hidden Filters", "@settingsHiddenFiltersTitle": {}, "settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.", @@ -889,14 +916,10 @@ "settingsHiddenFiltersEmpty": "No hidden filters", "@settingsHiddenFiltersEmpty": {}, - "settingsHiddenPathsTile": "Hidden paths", - "@settingsHiddenPathsTile": {}, "settingsHiddenPathsTitle": "Hidden Paths", "@settingsHiddenPathsTitle": {}, "settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.", "@settingsHiddenPathsBanner": {}, - "settingsHiddenPathsEmpty": "No hidden paths", - "@settingsHiddenPathsEmpty": {}, "addPathTooltip": "Add path", "@addPathTooltip": {}, @@ -1026,11 +1049,31 @@ "viewerInfoSearchSuggestionRights": "Rights", "@viewerInfoSearchSuggestionRights": {}, + "tagEditorPageTitle": "Edit Tags", + "@tagEditorPageTitle": {}, + "tagEditorPageNewTagFieldLabel": "New tag", + "@tagEditorPageNewTagFieldLabel": {}, + "tagEditorPageAddTagTooltip": "Add tag", + "@tagEditorPageAddTagTooltip": {}, + "tagEditorSectionRecent": "Recent", + "@tagEditorSectionRecent": {}, + "panoramaEnableSensorControl": "Enable sensor control", "@panoramaEnableSensorControl": {}, "panoramaDisableSensorControl": "Disable sensor control", "@panoramaDisableSensorControl": {}, "sourceViewerPageTitle": "Source", - "@sourceViewerPageTitle": {} + "@sourceViewerPageTitle": {}, + + "filePickerShowHiddenFiles": "Show hidden files", + "@filePickerShowHiddenFiles": {}, + "filePickerDoNotShowHiddenFiles": "Don’t show hidden files", + "@filePickerDoNotShowHiddenFiles": {}, + "filePickerOpenFrom": "Open from", + "@filePickerOpenFrom": {}, + "filePickerNoItems": "No items", + "@filePickerNoItems": {}, + "filePickerUseThisFolder": "Use this folder", + "@filePickerUseThisFolder": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 000000000..4ebb4930f --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -0,0 +1,523 @@ +{ + "appName": "Aves", + "welcomeMessage": "Bienvenue", + "welcomeOptional": "Option", + "welcomeTermsToggle": "J’accepte les conditions d’utilisation", + "itemCount": "{count, plural, =1{1 élément} other{{count} éléments}}", + + "timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}", + "timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}", + + "applyButtonLabel": "ENREGISTRER", + "deleteButtonLabel": "SUPPRIMER", + "nextButtonLabel": "SUIVANT", + "showButtonLabel": "AFFICHER", + "hideButtonLabel": "MASQUER", + "continueButtonLabel": "CONTINUER", + + "changeTooltip": "Modifier", + "clearTooltip": "Effacer", + "previousTooltip": "Précédent", + "nextTooltip": "Suivant", + "showTooltip": "Afficher", + "hideTooltip": "Masquer", + "removeTooltip": "Supprimer", + "resetButtonTooltip": "Réinitialiser", + + "doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.", + + "sourceStateLoading": "Chargement", + "sourceStateCataloguing": "Classification", + "sourceStateLocatingCountries": "Identification des pays", + "sourceStateLocatingPlaces": "Identification des lieux", + + "chipActionDelete": "Supprimer", + "chipActionGoToAlbumPage": "Afficher dans Albums", + "chipActionGoToCountryPage": "Afficher dans Pays", + "chipActionGoToTagPage": "Afficher dans Libellés", + "chipActionHide": "Masquer", + "chipActionPin": "Épingler", + "chipActionUnpin": "Retirer", + "chipActionRename": "Renommer", + "chipActionSetCover": "Modifier la couverture", + "chipActionCreateAlbum": "Créer un album", + + "entryActionCopyToClipboard": "Copier dans presse-papier", + "entryActionDelete": "Supprimer", + "entryActionExport": "Exporter", + "entryActionInfo": "Détails", + "entryActionRename": "Renommer", + "entryActionRotateCCW": "Pivoter à gauche", + "entryActionRotateCW": "Pivoter à droite", + "entryActionFlip": "Retourner horizontalement", + "entryActionPrint": "Imprimer", + "entryActionShare": "Partager", + "entryActionViewSource": "Voir le code", + "entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo", + "entryActionEdit": "Modifier avec…", + "entryActionOpen": "Ouvrir avec…", + "entryActionSetAs": "Utiliser comme…", + "entryActionOpenMap": "Localiser avec…", + "entryActionRotateScreen": "Pivoter l’écran", + "entryActionAddFavourite": "Ajouter aux favoris", + "entryActionRemoveFavourite": "Retirer des favoris", + + "videoActionCaptureFrame": "Capturer l’image", + "videoActionPause": "Pause", + "videoActionPlay": "Lire", + "videoActionReplay10": "Reculer de 10 secondes", + "videoActionSkip10": "Avancer de 10 secondes", + "videoActionSelectStreams": "Choisir les pistes", + "videoActionSetSpeed": "Vitesse de lecture", + "videoActionSettings": "Préférences", + + "entryInfoActionEditDate": "Modifier la date", + "entryInfoActionEditTags": "Modifier les libellés", + "entryInfoActionRemoveMetadata": "Retirer les métadonnées", + + "filterFavouriteLabel": "Favori", + "filterLocationEmptyLabel": "Sans lieu", + "filterTagEmptyLabel": "Sans libellé", + "filterTypeAnimatedLabel": "Animation", + "filterTypeMotionPhotoLabel": "Photo animée", + "filterTypePanoramaLabel": "Panorama", + "filterTypeRawLabel": "Raw", + "filterTypeSphericalVideoLabel": "Vidéo à 360°", + "filterTypeGeotiffLabel": "GeoTIFF", + "filterMimeImageLabel": "Image", + "filterMimeVideoLabel": "Vidéo", + + "coordinateFormatDms": "DMS", + "coordinateFormatDecimal": "Degrés décimaux", + "coordinateDms": "{coordinate} {direction}", + "coordinateDmsNorth": "N", + "coordinateDmsSouth": "S", + "coordinateDmsEast": "E", + "coordinateDmsWest": "O", + + "unitSystemMetric": "SI", + "unitSystemImperial": "anglo-saxonnes", + + "videoLoopModeNever": "Jamais", + "videoLoopModeShortOnly": "Courtes vidéos seulement", + "videoLoopModeAlways": "Toujours", + + "mapStyleGoogleNormal": "Google Maps", + "mapStyleGoogleHybrid": "Google Maps (Satellite)", + "mapStyleGoogleTerrain": "Google Maps (Relief)", + "mapStyleOsmHot": "OSM Humanitaire", + "mapStyleStamenToner": "Stamen Toner", + "mapStyleStamenWatercolor": "Stamen Watercolor", + + "nameConflictStrategyRename": "Renommer", + "nameConflictStrategyReplace": "Remplacer", + "nameConflictStrategySkip": "Ignorer", + + "keepScreenOnNever": "Jamais", + "keepScreenOnViewerOnly": "Visionneuse seulement", + "keepScreenOnAlways": "Toujours", + + "accessibilityAnimationsRemove": "Empêchez certains effets de l’écran", + "accessibilityAnimationsKeep": "Conserver les effets de l’écran", + + "albumTierNew": "Nouveaux", + "albumTierPinned": "Épinglés", + "albumTierSpecial": "Standards", + "albumTierApps": "Apps", + "albumTierRegular": "Autres", + + "storageVolumeDescriptionFallbackPrimary": "Stockage interne", + "storageVolumeDescriptionFallbackNonPrimary": "Carte SD", + "rootDirectoryDescription": "dossier racine", + "otherDirectoryDescription": "dossier «\u00A0{name}\u00A0»", + "storageAccessDialogTitle": "Accès au dossier", + "storageAccessDialogMessage": "Veuillez sélectionner le {directory} de «\u00A0{volume}\u00A0» à l’écran suivant, pour que l’app puisse modifier son contenu.", + "restrictedAccessDialogTitle": "Accès restreint", + "restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.", + "notEnoughSpaceDialogTitle": "Espace insuffisant", + "notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} d’espace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.", + + "unsupportedTypeDialogTitle": "Formats non supportés", + "unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération n’est pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération n’est pas disponible pour les fichiers aux formats suivants : {types}.}}", + + "nameConflictDialogSingleSourceMessage": "Certains fichiers dans le dossier de destination ont le même nom.", + "nameConflictDialogMultipleSourceMessage": "Certains fichiers ont le même nom.", + + "addShortcutDialogLabel": "Nom du raccourci", + "addShortcutButtonLabel": "AJOUTER", + + "noMatchingAppDialogTitle": "App indisponible", + "noMatchingAppDialogMessage": "Aucune app ne peut effectuer cette opération.", + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet élément ?} other{Voulez-vous vraiment supprimer ces {count} éléments ?}}", + + "videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?", + "videoStartOverButtonLabel": "RECOMMENCER", + "videoResumeButtonLabel": "REPRENDRE", + + "setCoverDialogTitle": "Modifier la couverture", + "setCoverDialogLatest": "dernier élément", + "setCoverDialogCustom": "personnalisé", + + "hideFilterConfirmationDialogMessage": "Les images et vidéos correspondantes n’apparaîtront plus dans votre collection. Vous pouvez les montrer à nouveau via les réglages de «\u00A0Confidentialité\u00A0».\n\nVoulez-vous vraiment les masquer ?", + + "newAlbumDialogTitle": "Nouvel Album", + "newAlbumDialogNameLabel": "Nom de l’album", + "newAlbumDialogNameLabelAlreadyExistsHelper": "Le dossier existe déjà", + "newAlbumDialogStorageLabel": "Volume de stockage :", + + "renameAlbumDialogLabel": "Nouveau nom", + "renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà", + + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet album et son élément ?} other{Voulez-vous vraiment supprimer cet album et ses {count} éléments ?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}", + + "exportEntryDialogFormat": "Format :", + + "renameEntryDialogLabel": "Nouveau nom", + + "editEntryDateDialogTitle": "Date & Heure", + "editEntryDateDialogSet": "Régler", + "editEntryDateDialogShift": "Décaler", + "editEntryDateDialogExtractFromTitle": "Extraire du titre", + "editEntryDateDialogClear": "Effacer", + "editEntryDateDialogFieldSelection": "Champs affectés", + "editEntryDateDialogHours": "Heures", + "editEntryDateDialogMinutes": "Minutes", + + "removeEntryMetadataDialogTitle": "Retrait de métadonnées", + "removeEntryMetadataDialogMore": "Voir plus", + + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Les métadonnées XMP sont nécessaires pour jouer la vidéo d’une photo animée.\n\nVoulez-vous vraiment les supprimer ?", + + "videoSpeedDialogLabel": "Vitesse de lecture", + + "videoStreamSelectionDialogVideo": "Vidéo", + "videoStreamSelectionDialogAudio": "Audio", + "videoStreamSelectionDialogText": "Sous-titres", + "videoStreamSelectionDialogOff": "Désactivé", + "videoStreamSelectionDialogTrack": "Piste", + "videoStreamSelectionDialogNoSelection": "Il n’y a pas d’autre piste.", + + "genericSuccessFeedback": "Succès !", + "genericFailureFeedback": "Échec", + + "menuActionSort": "Trier", + "menuActionGroup": "Grouper", + "menuActionSelect": "Sélectionner", + "menuActionSelectAll": "Tout sélectionner", + "menuActionSelectNone": "Tout désélectionner", + "menuActionMap": "Carte", + "menuActionStats": "Statistiques", + + "aboutPageTitle": "À propos", + "aboutLinkSources": "Sources", + "aboutLinkLicense": "Licence", + "aboutLinkPolicy": "Politique de confidentialité", + + "aboutUpdate": "Nouvelle Version", + "aboutUpdateLinks1": "Une nouvelle version d’Aves est disponible sur", + "aboutUpdateLinks2": "et", + "aboutUpdateLinks3": ".", + "aboutUpdateGitHub": "GitHub", + "aboutUpdateGooglePlay": "Google Play", + + "aboutBug": "Rapports d’erreur", + "aboutBugSaveLogInstruction": "Sauvegarder les logs de l’app vers un fichier", + "aboutBugSaveLogButton": "Sauvegarder", + "aboutBugCopyInfoInstruction": "Copier les informations d’environnement", + "aboutBugCopyInfoButton": "Copier", + "aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations d’environnement", + "aboutBugReportButton": "Créer", + + "aboutCredits": "Remerciements", + "aboutCreditsWorldAtlas1": "Cette app utilise un fichier TopoJSON de ", + "aboutCreditsWorldAtlas2": "sous licence ISC.", + "aboutCreditsTranslators": "Traducteurs :", + "aboutCreditsTranslatorLine": "{language} : {names}", + + "aboutLicenses": "Licences open-source", + "aboutLicensesBanner": "Cette app utilise ces librairies et packages open-source.", + "aboutLicensesAndroidLibraries": "Librairies Android", + "aboutLicensesFlutterPlugins": "Plugins Flutter", + "aboutLicensesFlutterPackages": "Packages Flutter", + "aboutLicensesDartPackages": "Packages Dart", + "aboutLicensesShowAllButtonLabel": "Afficher toutes les licences", + + "policyPageTitle": "Politique de confidentialité", + + "collectionPageTitle": "Collection", + "collectionPickPageTitle": "Sélection", + "collectionSelectionPageTitle": "{count, plural, =0{Sélection} =1{1 élément} other{{count} éléments}}", + + "collectionActionShowTitleSearch": "Filtrer les titres", + "collectionActionHideTitleSearch": "Masquer le filtre", + "collectionActionAddShortcut": "Créer un raccourci", + "collectionActionCopy": "Copier vers l’album", + "collectionActionMove": "Déplacer vers l’album", + "collectionActionRescan": "Réanalyser", + "collectionActionEdit": "Modifier", + + "collectionSearchTitlesHintText": "Recherche de titres", + + "collectionSortTitle": "Trier", + "collectionSortDate": "par date", + "collectionSortSize": "par taille", + "collectionSortName": "alphabétiquement", + + "collectionGroupTitle": "Grouper", + "collectionGroupAlbum": "par album", + "collectionGroupMonth": "par mois", + "collectionGroupDay": "par jour", + "collectionGroupNone": "ne pas grouper", + + "sectionUnknown": "Inconnu", + "dateToday": "Aujourd’hui", + "dateYesterday": "Hier", + "dateThisMonth": "Ce mois-ci", + "collectionDeleteFailureFeedback": "{count, plural, =1{Échec de la suppression d’1 élément} other{Échec de la suppression de {count} éléments}}", + "collectionCopyFailureFeedback": "{count, plural, =1{Échec de la copie d’1 élément} other{Échec de la copie de {count} éléments}}", + "collectionMoveFailureFeedback": "{count, plural, =1{Échec du déplacement d’1 élément} other{Échec du déplacement de {count} éléments}}", + "collectionEditFailureFeedback": "{count, plural, =1{Échec de la modification d’1 élément} other{Échec de la modification de {count} éléments}}", + "collectionExportFailureFeedback": "{count, plural, =1{Échec de l’export d’1 page} other{Échec de l’export de {count} pages}}", + "collectionCopySuccessFeedback": "{count, plural, =1{1 élément copié} other{{count} éléments copiés}}", + "collectionMoveSuccessFeedback": "{count, plural, =1{1 élément déplacé} other{{count} éléments déplacés}}", + "collectionEditSuccessFeedback": "{count, plural, =1{1 élément modifié} other{{count} éléments modifiés}}", + + "collectionEmptyFavourites": "Aucun favori", + "collectionEmptyVideos": "Aucune vidéo", + "collectionEmptyImages": "Aucune image", + + "collectionSelectSectionTooltip": "Sélectionner la section", + "collectionDeselectSectionTooltip": "Désélectionner la section", + + "drawerCollectionAll": "Toute la collection", + "drawerCollectionFavourites": "Favoris", + "drawerCollectionImages": "Images", + "drawerCollectionVideos": "Vidéos", + "drawerCollectionAnimated": "Animations", + "drawerCollectionMotionPhotos": "Photos animées", + "drawerCollectionPanoramas": "Panoramas", + "drawerCollectionRaws": "Photos Raw", + "drawerCollectionSphericalVideos": "Vidéos à 360°", + + "chipSortTitle": "Trier", + "chipSortDate": "par date", + "chipSortName": "par nom", + "chipSortCount": "par nombre d’éléments", + + "albumGroupTitle": "Grouper", + "albumGroupTier": "par importance", + "albumGroupVolume": "par volume de stockage", + "albumGroupNone": "ne pas grouper", + + "albumPickPageTitleCopy": "Copie", + "albumPickPageTitleExport": "Export", + "albumPickPageTitleMove": "Déplacement", + "albumPickPageTitlePick": "Sélection", + + "albumCamera": "Appareil photo", + "albumDownload": "Téléchargements", + "albumScreenshots": "Captures d’écran", + "albumScreenRecordings": "Enregistrements d’écran", + "albumVideoCaptures": "Captures de vidéo", + + "albumPageTitle": "Albums", + "albumEmpty": "Aucun album", + "createAlbumTooltip": "Créer un album", + "createAlbumButtonLabel": "CRÉER", + "newFilterBanner": "nouveau", + + "countryPageTitle": "Pays", + "countryEmpty": "Aucun pays", + + "tagPageTitle": "Libellés", + "tagEmpty": "Aucun libellé", + + "searchCollectionFieldHint": "Recherche", + "searchSectionRecent": "Historique", + "searchSectionAlbums": "Albums", + "searchSectionCountries": "Pays", + "searchSectionPlaces": "Lieux", + "searchSectionTags": "Libellés", + + "settingsPageTitle": "Réglages", + "settingsSystemDefault": "Système", + "settingsDefault": "Par défaut", + + "settingsActionExport": "Exporter", + "settingsActionImport": "Importer", + + "settingsSectionNavigation": "Navigation", + "settingsHome": "Page d’accueil", + "settingsKeepScreenOnTile": "Maintenir l’écran allumé", + "settingsKeepScreenOnTitle": "Allumage de l’écran", + "settingsDoubleBackExit": "Presser «\u00A0retour\u00A0» 2 fois pour quitter", + + "settingsNavigationDrawerTile": "Menu de navigation", + "settingsNavigationDrawerEditorTitle": "Menu de navigation", + "settingsNavigationDrawerBanner": "Maintenez votre doigt appuyé pour déplacer et réorganiser les éléments de menu.", + "settingsNavigationDrawerTabTypes": "Types", + "settingsNavigationDrawerTabAlbums": "Albums", + "settingsNavigationDrawerTabPages": "Pages", + "settingsNavigationDrawerAddAlbum": "Ajouter un album", + + "settingsSectionThumbnails": "Vignettes", + "settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu", + "settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée", + "settingsThumbnailShowRawIcon": "Afficher l’icône de photo raw", + "settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo", + + "settingsCollectionQuickActionsTile": "Actions rapides", + "settingsCollectionQuickActionEditorTitle": "Actions rapides", + "settingsCollectionQuickActionTabBrowsing": "Navigation", + "settingsCollectionQuickActionTabSelecting": "Sélection", + "settingsCollectionBrowsingQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la navigation.", + "settingsCollectionSelectionQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la sélection d’éléments.", + + "settingsSectionViewer": "Visionneuse", + "settingsViewerUseCutout": "Utiliser la zone d’encoche", + "settingsViewerMaximumBrightness": "Luminosité maximale", + "settingsImageBackground": "Arrière-plan de l’image", + + "settingsViewerQuickActionsTile": "Actions rapides", + "settingsViewerQuickActionEditorTitle": "Actions rapides", + "settingsViewerQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées sur la visionneuse.", + "settingsViewerQuickActionEditorDisplayedButtons": "Boutons affichés", + "settingsViewerQuickActionEditorAvailableButtons": "Boutons disponibles", + "settingsViewerQuickActionEmpty": "Aucun bouton", + + "settingsViewerOverlayTile": "Incrustations", + "settingsViewerOverlayTitle": "Incrustations", + "settingsViewerShowOverlayOnOpening": "Afficher à l’ouverture", + "settingsViewerShowMinimap": "Afficher la mini-carte", + "settingsViewerShowInformation": "Afficher les détails", + "settingsViewerShowInformationSubtitle": "Afficher les titre, date, lieu, etc.", + "settingsViewerShowShootingDetails": "Afficher les détails de prise de vue", + "settingsViewerEnableOverlayBlurEffect": "Effets de flou", + + "settingsVideoPageTitle": "Réglages vidéo", + "settingsSectionVideo": "Vidéo", + "settingsVideoShowVideos": "Afficher les vidéos", + "settingsVideoEnableHardwareAcceleration": "Accélération matérielle", + "settingsVideoEnableAutoPlay": "Lecture automatique", + "settingsVideoLoopModeTile": "Lecture répétée", + "settingsVideoLoopModeTitle": "Lecture répétée", + "settingsVideoQuickActionsTile": "Actions rapides pour les vidéos", + "settingsVideoQuickActionEditorTitle": "Actions rapides", + + "settingsSubtitleThemeTile": "Sous-titres", + "settingsSubtitleThemeTitle": "Sous-titres", + "settingsSubtitleThemeSample": "Ceci est un exemple.", + "settingsSubtitleThemeTextAlignmentTile": "Alignement du texte", + "settingsSubtitleThemeTextAlignmentTitle": "Alignement du texte", + "settingsSubtitleThemeTextSize": "Taille du texte", + "settingsSubtitleThemeShowOutline": "Afficher les contours et ombres", + "settingsSubtitleThemeTextColor": "Couleur du texte", + "settingsSubtitleThemeTextOpacity": "Transparence du texte", + "settingsSubtitleThemeBackgroundColor": "Couleur d’arrière-plan", + "settingsSubtitleThemeBackgroundOpacity": "Transparence de l’arrière-plan", + "settingsSubtitleThemeTextAlignmentLeft": "gauche", + "settingsSubtitleThemeTextAlignmentCenter": "centré", + "settingsSubtitleThemeTextAlignmentRight": "droite", + + "settingsSectionPrivacy": "Confidentialité", + "settingsAllowInstalledAppAccess": "Autoriser l’accès à l’inventaire des apps", + "settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums", + "settingsAllowErrorReporting": "Autoriser l’envoi de rapports d’erreur", + "settingsSaveSearchHistory": "Maintenir un historique des recherches", + + "settingsHiddenItemsTile": "Éléments masqués", + "settingsHiddenItemsTitle": "Éléments masqués", + + "settingsHiddenFiltersTitle": "Filtres masqués", + "settingsHiddenFiltersBanner": "Les images et vidéos correspondantes aux filtres masqués n’apparaîtront pas dans votre collection.", + "settingsHiddenFiltersEmpty": "Aucun filtre masqué", + + "settingsHiddenPathsTitle": "Chemins masqués", + "settingsHiddenPathsBanner": "Les images et vidéos dans ces dossiers, ou leurs sous-dossiers, n’apparaîtront pas dans votre collection.", + "addPathTooltip": "Ajouter un chemin", + + "settingsStorageAccessTile": "Accès au stockage", + "settingsStorageAccessTitle": "Accès au stockage", + "settingsStorageAccessBanner": "Une autorisation d’accès au stockage est nécessaire pour modifier le contenu de certains dossiers. Voici la liste des autorisations couramment en vigueur.", + "settingsStorageAccessEmpty": "Aucune autorisation d’accès", + "settingsStorageAccessRevokeTooltip": "Retirer", + + "settingsSectionAccessibility": "Accessibilité", + "settingsRemoveAnimationsTile": "Suppression des animations", + "settingsRemoveAnimationsTitle": "Suppression des animations", + "settingsTimeToTakeActionTile": "Délai pour effectuer une action", + "settingsTimeToTakeActionTitle": "Délai pour effectuer une action", + + "settingsSectionLanguage": "Langue & Formats", + "settingsLanguage": "Langue", + "settingsCoordinateFormatTile": "Format de coordonnées", + "settingsCoordinateFormatTitle": "Format de coordonnées", + "settingsUnitSystemTile": "Unités", + "settingsUnitSystemTitle": "Unités", + + "statsPageTitle": "Statistiques", + "statsWithGps": "{count, plural, =1{1 élément localisé} other{{count} éléments localisés}}", + "statsTopCountries": "Top pays", + "statsTopPlaces": "Top lieux", + "statsTopTags": "Top libellés", + + "viewerOpenPanoramaButtonLabel": "OUVRIR LE PANORAMA", + "viewerErrorUnknown": "Zut !", + "viewerErrorDoesNotExist": "Le fichier n’existe plus.", + + "viewerInfoPageTitle": "Détails", + "viewerInfoBackToViewerTooltip": "Retour à la visionneuse", + + "viewerInfoUnknown": "inconnu", + "viewerInfoLabelTitle": "Titre", + "viewerInfoLabelDate": "Date", + "viewerInfoLabelResolution": "Résolution", + "viewerInfoLabelSize": "Taille", + "viewerInfoLabelUri": "URI", + "viewerInfoLabelPath": "Chemin", + "viewerInfoLabelDuration": "Durée", + "viewerInfoLabelOwner": "Propriétaire", + "viewerInfoLabelCoordinates": "Coordonnées", + "viewerInfoLabelAddress": "Adresse", + + "mapStyleTitle": "Type de carte", + "mapStyleTooltip": "Sélectionner le type de carte", + "mapZoomInTooltip": "Zoomer", + "mapZoomOutTooltip": "Dézoomer", + "mapPointNorthUpTooltip": "Placer le nord en haut", + "mapAttributionOsmHot": "Données © les contributeurs d’[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [HOT](https://www.hotosm.org/) • Hébergé par [OSM France](https://openstreetmap.fr/)", + "mapAttributionStamen": "Données © les contributeurs d’[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", + "openMapPageTooltip": "Ouvrir la page Carte", + "mapEmptyRegion": "Aucune image dans cette région", + + "viewerInfoOpenEmbeddedFailureFeedback": "Échec de l’extraction des données", + "viewerInfoOpenLinkText": "Ouvrir", + "viewerInfoViewXmlLinkText": "Afficher le XML", + + "viewerInfoSearchFieldLabel": "Recherche de métadonnées", + "viewerInfoSearchEmpty": "Aucune clé correspondante", + "viewerInfoSearchSuggestionDate": "Date & heure", + "viewerInfoSearchSuggestionDescription": "Description", + "viewerInfoSearchSuggestionDimensions": "Dimensions", + "viewerInfoSearchSuggestionResolution": "Résolution", + "viewerInfoSearchSuggestionRights": "Droits", + + "tagEditorPageTitle": "Modifier les libellés", + "tagEditorPageNewTagFieldLabel": "Nouveau libellé", + "tagEditorPageAddTagTooltip": "Ajouter le libellé", + "tagEditorSectionRecent": "Ajouts récents", + + "panoramaEnableSensorControl": "Activer le contrôle par capteurs", + "panoramaDisableSensorControl": "Désactiver le contrôle par capteurs", + + "sourceViewerPageTitle": "Code source", + "@sourceViewerPageTitle": {}, + + "filePickerShowHiddenFiles": "Afficher les fichiers masqués", + "filePickerDoNotShowHiddenFiles": "Ne pas afficher les fichiers masqués", + "filePickerOpenFrom": "Ouvrir à partir de", + "filePickerNoItems": "Aucun élément", + "filePickerUseThisFolder": "Utiliser ce dossier" +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 296bdac15..5d134e0a2 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -22,6 +22,7 @@ "showTooltip": "보기", "hideTooltip": "숨기기", "removeTooltip": "제거", + "resetButtonTooltip": "복원", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.", @@ -71,6 +72,7 @@ "videoActionSettings": "설정", "entryInfoActionEditDate": "날짜와 시간 수정", + "entryInfoActionEditTags": "태그 수정", "entryInfoActionRemoveMetadata": "메타데이터 삭제", "filterFavouriteLabel": "즐겨찾기", @@ -186,7 +188,7 @@ "removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogMore": "더 보기", - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?", "videoSpeedDialogLabel": "재생 배속", @@ -232,6 +234,7 @@ "aboutCreditsWorldAtlas1": "이 앱은", "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", "aboutCreditsTranslators": "번역가:", + "aboutCreditsTranslatorLine": "{language}: {names}", "aboutLicenses": "오픈 소스 라이선스", "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", @@ -292,6 +295,7 @@ "drawerCollectionFavourites": "즐겨찾기", "drawerCollectionImages": "사진", "drawerCollectionVideos": "동영상", + "drawerCollectionAnimated": "애니메이션", "drawerCollectionMotionPhotos": "모션 포토", "drawerCollectionPanoramas": "파노라마", "drawerCollectionRaws": "Raw 이미지", @@ -372,13 +376,8 @@ "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsSectionViewer": "뷰어", - "settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시", - "settingsViewerShowMinimap": "미니맵 표시", - "settingsViewerShowInformation": "상세 정보 표시", - "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", - "settingsViewerShowShootingDetails": "촬영 정보 표시", - "settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과", "settingsViewerUseCutout": "컷아웃 영역 사용", + "settingsViewerMaximumBrightness": "최대 밝기", "settingsImageBackground": "이미지 배경", "settingsViewerQuickActionsTile": "빠른 작업", @@ -388,6 +387,15 @@ "settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼", "settingsViewerQuickActionEmpty": "버튼이 없습니다", + "settingsViewerOverlayTile": "오버레이", + "settingsViewerOverlayTitle": "오버레이", + "settingsViewerShowOverlayOnOpening": "열릴 때 표시", + "settingsViewerShowMinimap": "미니맵 표시", + "settingsViewerShowInformation": "상세 정보 표시", + "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", + "settingsViewerShowShootingDetails": "촬영 정보 표시", + "settingsViewerEnableOverlayBlurEffect": "흐림 효과", + "settingsVideoPageTitle": "동영상 설정", "settingsSectionVideo": "동영상", "settingsVideoShowVideos": "미디어에 동영상 표시", @@ -419,15 +427,15 @@ "settingsAllowErrorReporting": "오류 보고서 보내기", "settingsSaveSearchHistory": "검색기록", - "settingsHiddenFiltersTile": "숨겨진 필터", + "settingsHiddenItemsTile": "숨겨진 항목", + "settingsHiddenItemsTitle": "숨겨진 항목", + "settingsHiddenFiltersTitle": "숨겨진 필터", "settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", "settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다", - "settingsHiddenPathsTile": "숨겨진 경로", "settingsHiddenPathsTitle": "숨겨진 경로", "settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", - "settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다", "addPathTooltip": "경로 추가", "settingsStorageAccessTile": "저장공간 접근", @@ -496,8 +504,19 @@ "viewerInfoSearchSuggestionResolution": "해상도", "viewerInfoSearchSuggestionRights": "권리", + "tagEditorPageTitle": "태그 수정", + "tagEditorPageNewTagFieldLabel": "새 태그", + "tagEditorPageAddTagTooltip": "태그 추가", + "tagEditorSectionRecent": "최근 이용기록", + "panoramaEnableSensorControl": "센서 제어 활성화", "panoramaDisableSensorControl": "센서 제어 비활성화", - "sourceViewerPageTitle": "소스 코드" + "sourceViewerPageTitle": "소스 코드", + + "filePickerShowHiddenFiles": "숨겨진 파일 표시", + "filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함", + "filePickerOpenFrom": "다음에서 열기:", + "filePickerNoItems": "항목 없음", + "filePickerUseThisFolder": "이 폴더 사용" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1dbbe07f0..0147debeb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -186,7 +186,7 @@ "removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogMore": "Дополнительно", - "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?", + "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?", "videoSpeedDialogLabel": "Скорость воспроизведения", @@ -232,6 +232,7 @@ "aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из", "aboutCreditsWorldAtlas2": "под лицензией ISC.", "aboutCreditsTranslators": "Переводчики:", + "aboutCreditsTranslatorLine": "{language}: {names}", "aboutLicenses": "Лицензии с открытым исходным кодом", "aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.", @@ -292,6 +293,7 @@ "drawerCollectionFavourites": "Избранное", "drawerCollectionImages": "Изображения", "drawerCollectionVideos": "Видео", + "drawerCollectionAnimated": "GIF", "drawerCollectionMotionPhotos": "Живые фото", "drawerCollectionPanoramas": "Панорамы", "drawerCollectionRaws": "RAW", @@ -372,12 +374,6 @@ "settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.", "settingsSectionViewer": "Просмотрщик", - "settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии", - "settingsViewerShowMinimap": "Показать миникарту", - "settingsViewerShowInformation": "Показывать информацию", - "settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.", - "settingsViewerShowShootingDetails": "Показать детали съёмки", - "settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия", "settingsViewerUseCutout": "Использовать область выреза", "settingsImageBackground": "Фон изображения", @@ -388,6 +384,15 @@ "settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки", "settingsViewerQuickActionEmpty": "Нет кнопок", + "settingsViewerOverlayTile": "Наложение", + "settingsViewerOverlayTitle": "Наложение", + "settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии", + "settingsViewerShowMinimap": "Показать миникарту", + "settingsViewerShowInformation": "Показывать информацию", + "settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.", + "settingsViewerShowShootingDetails": "Показать детали съёмки", + "settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия", + "settingsVideoPageTitle": "Настройки видео", "settingsSectionVideo": "Видео", "settingsVideoShowVideos": "Показывать видео", @@ -419,15 +424,15 @@ "settingsAllowErrorReporting": "Разрешить анонимную отправку логов", "settingsSaveSearchHistory": "Сохранять историю поиска", - "settingsHiddenFiltersTile": "Скрытые фильтры", + "settingsHiddenItemsTile": "Скрытые объекты", + "settingsHiddenItemsTitle": "Скрытые объекты", + "settingsHiddenFiltersTitle": "Скрытые фильтры", "settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.", "settingsHiddenFiltersEmpty": "Нет скрытых фильтров", - "settingsHiddenPathsTile": "Скрытые каталоги", "settingsHiddenPathsTitle": "Скрытые каталоги", "settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.", - "settingsHiddenPathsEmpty": "Нет скрытых каталогов", "addPathTooltip": "Добавить каталог", "settingsStorageAccessTile": "Доступ к хранилищу", @@ -496,8 +501,16 @@ "viewerInfoSearchSuggestionResolution": "Разрешение", "viewerInfoSearchSuggestionRights": "Права", + "tagEditorSectionRecent": "Недавние", + "panoramaEnableSensorControl": "Включить сенсорное управление", "panoramaDisableSensorControl": "Отключить сенсорное управление", - "sourceViewerPageTitle": "Источник" + "sourceViewerPageTitle": "Источник", + + "filePickerShowHiddenFiles": "Показывать скрытые файлы", + "filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы", + "filePickerOpenFrom": "Открыть", + "filePickerNoItems": "Ничего нет.", + "filePickerUseThisFolder": "Использовать эту папку" } diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index f0c6c7805..901133187 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; enum EntryInfoAction { // general editDate, + editTags, removeMetadata, // motion photo viewMotionPhotoVideo, @@ -13,6 +14,7 @@ enum EntryInfoAction { class EntryInfoActions { static const all = [ EntryInfoAction.editDate, + EntryInfoAction.editTags, EntryInfoAction.removeMetadata, EntryInfoAction.viewMotionPhotoVideo, ]; @@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntryInfoAction.editTags: + return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: return context.l10n.entryInfoActionRemoveMetadata; // motion photo @@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { // general case EntryInfoAction.editDate: return AIcons.date; + case EntryInfoAction.editTags: + return AIcons.addTag; case EntryInfoAction.removeMetadata: return AIcons.clear; // motion photo diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart index f85e69d6f..0db4efa6e 100644 --- a/lib/model/actions/entry_set_actions.dart +++ b/lib/model/actions/entry_set_actions.dart @@ -26,6 +26,7 @@ enum EntrySetAction { rotateCW, flip, editDate, + editTags, removeMetadata, } @@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction { return context.l10n.entryActionFlip; case EntrySetAction.editDate: return context.l10n.entryInfoActionEditDate; + case EntrySetAction.editTags: + return context.l10n.entryInfoActionEditTags; case EntrySetAction.removeMetadata: return context.l10n.entryInfoActionRemoveMetadata; } @@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction { return AIcons.flip; case EntrySetAction.editDate: return AIcons.date; + case EntrySetAction.editTags: + return AIcons.addTag; case EntrySetAction.removeMetadata: return AIcons.clear; } diff --git a/lib/model/actions/events.dart b/lib/model/actions/events.dart new file mode 100644 index 000000000..248e9dd88 --- /dev/null +++ b/lib/model/actions/events.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ActionEvent { + final T action; + + const ActionEvent(this.action); +} + +@immutable +class ActionStartedEvent extends ActionEvent { + const ActionStartedEvent(T action) : super(action); +} + +@immutable +class ActionEndedEvent extends ActionEvent { + const ActionEndedEvent(T action) : super(action); +} diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 828fda49e..ebb7215e1 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -17,6 +18,8 @@ abstract class AvesAvailability { Future get canLocatePlaces; + Future get canUseGoogleMaps; + Future get isNewVersionAvailable; } @@ -59,6 +62,9 @@ class LiveAvesAvailability implements AvesAvailability { @override Future get canLocatePlaces => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); + @override + Future get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices; + @override Future get isNewVersionAvailable async { if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!); diff --git a/lib/model/device.dart b/lib/model/device.dart new file mode 100644 index 000000000..0e4296866 --- /dev/null +++ b/lib/model/device.dart @@ -0,0 +1,43 @@ +import 'package:aves/services/common/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +final Device device = Device._private(); + +class Device { + late final String _userAgent; + late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRenderGoogleMaps; + late final bool _hasFilePicker, _showPinShortcutFeedback; + + String get userAgent => _userAgent; + + bool get canGrantDirectoryAccess => _canGrantDirectoryAccess; + + bool get canPinShortcut => _canPinShortcut; + + bool get canPrint => _canPrint; + + bool get canRenderFlagEmojis => _canRenderFlagEmojis; + + bool get canRenderGoogleMaps => _canRenderGoogleMaps; + + // TODO TLAD toggle settings > import/export, about > bug report > save + bool get hasFilePicker => _hasFilePicker; + + bool get showPinShortcutFeedback => _showPinShortcutFeedback; + + Device._private(); + + Future init() async { + final packageInfo = await PackageInfo.fromPlatform(); + _userAgent = '${packageInfo.packageName}/${packageInfo.version}'; + + final capabilities = await deviceService.getCapabilities(); + _canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false; + _canPinShortcut = capabilities['canPinShortcut'] ?? false; + _canPrint = capabilities['canPrint'] ?? false; + _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; + _canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false; + _hasFilePicker = capabilities['hasFilePicker'] ?? false; + _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; + } +} diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 6357ae9a7..f42169aeb 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; +enum EntryDataType { basic, catalog, address, references } + class AvesEntry { String uri; String? _path, _directory, _filename, _extension; @@ -235,6 +237,10 @@ class AvesEntry { bool get canEdit => path != null; + bool get canEditDate => canEdit && canEditExif; + + bool get canEditTags => canEdit && canEditXmp; + bool get canRotateAndFlip => canEdit && canEditExif; // as of androidx.exifinterface:exifinterface:1.3.3 @@ -250,6 +256,30 @@ class AvesEntry { } } + // as of latest PixyMeta + bool get canEditIptc { + switch (mimeType.toLowerCase()) { + case MimeTypes.jpeg: + case MimeTypes.tiff: + return true; + default: + return false; + } + } + + // as of latest PixyMeta + bool get canEditXmp { + switch (mimeType.toLowerCase()) { + case MimeTypes.gif: + case MimeTypes.jpeg: + case MimeTypes.png: + case MimeTypes.tiff: + return true; + default: + return false; + } + } + // as of latest PixyMeta bool get canRemoveMetadata { switch (mimeType.toLowerCase()) { @@ -394,11 +424,11 @@ class AvesEntry { LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; - List? _xmpSubjects; + Set? _tags; - List get xmpSubjects { - _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? []; - return _xmpSubjects!; + Set get tags { + _tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {}; + return _tags!; } String? _bestTitle; @@ -408,13 +438,15 @@ class AvesEntry { return _bestTitle; } - CatalogMetadata? get catalogMetadata => _catalogMetadata; + int? get catalogDateMillis => _catalogDateMillis; set catalogDateMillis(int? dateMillis) { _catalogDateMillis = dateMillis; _bestDate = null; } + CatalogMetadata? get catalogMetadata => _catalogMetadata; + set catalogMetadata(CatalogMetadata? newMetadata) { final oldDateModifiedSecs = dateModifiedSecs; final oldRotationDegrees = rotationDegrees; @@ -423,8 +455,8 @@ class AvesEntry { catalogDateMillis = newMetadata?.dateMillis; _catalogMetadata = newMetadata; _bestTitle = null; - _xmpSubjects = null; - metadataChangeNotifier.notifyListeners(); + _tags = null; + metadataChangeNotifier.notify(); _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); } @@ -434,7 +466,7 @@ class AvesEntry { addressDetails = null; } - Future catalog({required bool background, required bool persist, required bool force}) async { + Future catalog({required bool background, required bool force, required bool persist}) async { if (isCatalogued && !force) return; if (isSvg) { // vector image sizing is not essential, so we should not spend time for it during loading @@ -466,7 +498,7 @@ class AvesEntry { set addressDetails(AddressDetails? newAddress) { _addressDetails = newAddress; - addressChangeNotifier.notifyListeners(); + addressChangeNotifier.notify(); } Future locate({required bool background, required bool force, required Locale geocoderLocale}) async { @@ -590,61 +622,83 @@ class AvesEntry { } await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); - metadataChangeNotifier.notifyListeners(); + metadataChangeNotifier.notify(); } - Future refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async { - _catalogMetadata = null; - _addressDetails = null; + Future refresh({ + required bool background, + required bool persist, + required Set dataTypes, + required Locale geocoderLocale, + }) async { + // clear derived fields _bestDate = null; _bestTitle = null; - _xmpSubjects = null; + _tags = null; + if (persist) { - await metadataDb.removeIds({contentId!}, metadataOnly: true); + await metadataDb.removeIds({contentId!}, dataTypes: dataTypes); } - final updated = await mediaFileService.getEntry(uri, mimeType); - if (updated != null) { - await _applyNewFields(updated.toMap(), persist: persist); - await catalog(background: background, persist: persist, force: force); - await locate(background: background, force: force, geocoderLocale: geocoderLocale); + final updatedEntry = await mediaFileService.getEntry(uri, mimeType); + if (updatedEntry != null) { + await _applyNewFields(updatedEntry.toMap(), persist: persist); } + await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist); + await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale); } - Future rotate({required bool clockwise, required bool persist}) async { + Future> rotate({required bool clockwise, required bool persist}) async { final newFields = await metadataEditService.rotate(this, clockwise: clockwise); - if (newFields.isEmpty) return false; + if (newFields.isEmpty) return {}; await _applyNewFields(newFields, persist: persist); - return true; + return { + EntryDataType.basic, + EntryDataType.catalog, + }; } - Future flip({required bool persist}) async { + Future> flip({required bool persist}) async { final newFields = await metadataEditService.flip(this); - if (newFields.isEmpty) return false; + if (newFields.isEmpty) return {}; await _applyNewFields(newFields, persist: persist); - return true; + return { + EntryDataType.basic, + EntryDataType.catalog, + }; } - Future editDate(DateModifier modifier) async { + Future> editDate(DateModifier modifier) async { if (modifier.action == DateEditAction.extractFromTitle) { final _title = bestTitle; - if (_title == null) return false; + if (_title == null) return {}; final date = parseUnknownDateFormat(_title); if (date == null) { await reportService.recordError('failed to parse date from title=$_title', null); - return false; + return {}; } modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); } final newFields = await metadataEditService.editDate(this, modifier); - return newFields.isNotEmpty; + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + }; } - Future removeMetadata(Set types) async { + Future> removeMetadata(Set types) async { final newFields = await metadataEditService.removeTypes(this, types); - return newFields.isNotEmpty; + return newFields.isEmpty + ? {} + : { + EntryDataType.basic, + EntryDataType.catalog, + EntryDataType.address, + }; } Future delete() { @@ -665,7 +719,7 @@ class AvesEntry { Future _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); - imageChangeNotifier.notifyListeners(); + imageChangeNotifier.notify(); } } diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index 16a11be8b..3a281c733 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -extension ExtraAvesEntry on AvesEntry { +extension ExtraAvesEntryImages on AvesEntry { bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); ThumbnailProvider getThumbnail({double extent = 0}) { diff --git a/lib/model/entry_xmp_iptc.dart b/lib/model/entry_xmp_iptc.dart new file mode 100644 index 000000000..f5a1a80f6 --- /dev/null +++ b/lib/model/entry_xmp_iptc.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/ref/iptc.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:xml/xml.dart'; + +extension ExtraAvesEntryXmpIptc on AvesEntry { + static const dcNamespace = 'http://purl.org/dc/elements/1.1/'; + static const rdfNamespace = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; + static const xNamespace = 'adobe:ns:meta/'; + static const xmpNamespace = 'http://ns.adobe.com/xap/1.0/'; + static const xmpNoteNamespace = 'http://ns.adobe.com/xmp/note/'; + + static const xmlnsPrefix = 'xmlns'; + + static final nsDefaultPrefixes = { + dcNamespace: 'dc', + rdfNamespace: 'rdf', + xNamespace: 'x', + xmpNamespace: 'xmp', + xmpNoteNamespace: 'xmpNote', + }; + + // elements + static const xXmpmeta = 'xmpmeta'; + static const rdfRoot = 'RDF'; + static const rdfDescription = 'Description'; + static const dcSubject = 'subject'; + + // attributes + static const xXmptk = 'xmptk'; + static const rdfAbout = 'about'; + static const xmpMetadataDate = 'MetadataDate'; + static const xmpModifyDate = 'ModifyDate'; + static const xmpNoteHasExtendedXMP = 'HasExtendedXMP'; + + static String prefixOf(String ns) => nsDefaultPrefixes[ns] ?? ''; + + Future> editTags(Set tags) async { + final xmp = await metadataFetchService.getXmp(this); + final extendedXmpString = xmp?.extendedXmpString; + + XmlDocument? xmpDoc; + if (xmp != null) { + final xmpString = xmp.xmpString; + if (xmpString != null) { + xmpDoc = XmlDocument.parse(xmpString); + } + } + if (xmpDoc == null) { + final toolkit = 'Aves v${(await PackageInfo.fromPlatform()).version}'; + final builder = XmlBuilder(); + builder.namespace(xNamespace, prefixOf(xNamespace)); + builder.element(xXmpmeta, namespace: xNamespace, namespaces: { + xNamespace: prefixOf(xNamespace), + }, attributes: { + '${prefixOf(xNamespace)}:$xXmptk': toolkit, + }); + xmpDoc = builder.buildDocument(); + } + + final root = xmpDoc.rootElement; + XmlNode? rdf = root.getElement(rdfRoot, namespace: rdfNamespace); + if (rdf == null) { + final builder = XmlBuilder(); + builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); + builder.element(rdfRoot, namespace: rdfNamespace, namespaces: { + rdfNamespace: prefixOf(rdfNamespace), + }); + // get element because doc fragment cannot be used to edit + root.children.add(builder.buildFragment()); + rdf = root.getElement(rdfRoot, namespace: rdfNamespace)!; + } + + XmlNode? description = rdf.getElement(rdfDescription, namespace: rdfNamespace); + if (description == null) { + final builder = XmlBuilder(); + builder.namespace(rdfNamespace, prefixOf(rdfNamespace)); + builder.element(rdfDescription, namespace: rdfNamespace, attributes: { + '${prefixOf(rdfNamespace)}:$rdfAbout': '', + }); + rdf.children.add(builder.buildFragment()); + // get element because doc fragment cannot be used to edit + description = rdf.getElement(rdfDescription, namespace: rdfNamespace)!; + } + _setNamespaces(description, { + dcNamespace: prefixOf(dcNamespace), + xmpNamespace: prefixOf(xmpNamespace), + }); + + _setStringBag(description, dcSubject, tags, namespace: dcNamespace); + + if (_isMeaningfulXmp(rdf)) { + final modifyDate = DateTime.now(); + description.setAttribute('${prefixOf(xmpNamespace)}:$xmpMetadataDate', _toXmpDate(modifyDate)); + description.setAttribute('${prefixOf(xmpNamespace)}:$xmpModifyDate', _toXmpDate(modifyDate)); + } else { + // clear XMP if there are no attributes or elements worth preserving + xmpDoc = null; + } + + final editedXmp = AvesXmp( + xmpString: xmpDoc?.toXmlString(), + extendedXmpString: extendedXmpString, + ); + + if (canEditIptc) { + final iptc = await metadataFetchService.getIptc(this); + if (iptc != null) { + await _setIptcKeywords(iptc, tags); + } + } + + final newFields = await metadataEditService.setXmp(this, editedXmp); + return newFields.isEmpty ? {} : {EntryDataType.catalog}; + } + + Future _setIptcKeywords(List> iptc, Set tags) async { + iptc.removeWhere((v) => v['record'] == IPTC.applicationRecord && v['tag'] == IPTC.keywordsTag); + iptc.add({ + 'record': IPTC.applicationRecord, + 'tag': IPTC.keywordsTag, + 'values': tags.map((v) => utf8.encode(v)).toList(), + }); + await metadataEditService.setIptc(this, iptc, postEditScan: false); + } + + int _meaningfulChildrenCount(XmlNode node) => node.children.where((v) => v.nodeType != XmlNodeType.TEXT || v.text.trim().isNotEmpty).length; + + bool _isMeaningfulXmp(XmlNode rdf) { + if (_meaningfulChildrenCount(rdf) > 1) return true; + + final description = rdf.getElement(rdfDescription, namespace: rdfNamespace); + if (description == null) return true; + + if (_meaningfulChildrenCount(description) > 0) return true; + + final hasMeaningfulAttributes = description.attributes.any((v) { + switch (v.name.local) { + case rdfAbout: + return v.value.isNotEmpty; + case xmpMetadataDate: + case xmpModifyDate: + return false; + default: + switch (v.name.prefix) { + case xmlnsPrefix: + return false; + default: + // if the attribute got defined with the prefix as part of the name, + // the prefix is not recognized as such, so we check the full name + return !v.name.qualified.startsWith(xmlnsPrefix); + } + } + }); + return hasMeaningfulAttributes; + } + + // return time zone designator, formatted as `Z` or `+hh:mm` or `-hh:mm` + // as of intl v0.17.0, formatting time zone offset is not implemented + String _xmpTimeZoneDesignator(DateTime date) { + final offsetMinutes = date.timeZoneOffset.inMinutes; + final abs = offsetMinutes.abs(); + final h = abs ~/ Duration.minutesPerHour; + final m = abs % Duration.minutesPerHour; + return '${offsetMinutes.isNegative ? '-' : '+'}${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + } + + String _toXmpDate(DateTime date) => '${DateFormat('yyyy-MM-ddTHH:mm:ss').format(date)}${_xmpTimeZoneDesignator(date)}'; + + void _setNamespaces(XmlNode node, Map namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri)); + + void _setStringBag(XmlNode node, String name, Set values, {required String namespace}) { + // remove existing + node.findElements(name, namespace: namespace).toSet().forEach(node.children.remove); + + if (values.isNotEmpty) { + // add new bag + final rootBuilder = XmlBuilder(); + rootBuilder.namespace(namespace, prefixOf(namespace)); + rootBuilder.element(name, namespace: namespace); + node.children.add(rootBuilder.buildFragment()); + + final bagBuilder = XmlBuilder(); + bagBuilder.namespace(rdfNamespace, prefixOf(rdfNamespace)); + bagBuilder.element('Bag', namespace: rdfNamespace, nest: () { + values.forEach((v) { + bagBuilder.element('li', namespace: rdfNamespace, nest: v); + }); + }); + node.children.last.children.add(bagBuilder.buildFragment()); + } + } +} + +@immutable +class AvesXmp extends Equatable { + final String? xmpString; + final String? extendedXmpString; + + @override + List get props => [xmpString, extendedXmpString]; + + const AvesXmp({ + required this.xmpString, + this.extendedXmpString, + }); + + static AvesXmp? fromList(List xmpStrings) { + switch (xmpStrings.length) { + case 0: + return null; + case 1: + return AvesXmp(xmpString: xmpStrings.single); + default: + final byExtending = groupBy(xmpStrings, (v) => v.contains(':HasExtendedXMP=')); + final extending = byExtending[true] ?? []; + final extension = byExtending[false] ?? []; + if (extending.length == 1 && extension.length == 1) { + return AvesXmp( + xmpString: extending.single, + extendedXmpString: extension.single, + ); + } + + // take the first XMP and ignore the rest when the file is weirdly constructed + debugPrint('warning: entry has ${xmpStrings.length} XMP directories, xmpStrings=$xmpStrings'); + return AvesXmp(xmpString: xmpStrings.firstOrNull); + } + } +} diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 4bf3e7501..e5e8654dd 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -66,7 +66,9 @@ class AlbumFilter extends CollectionFilter { return PaletteGenerator.fromImageProvider( AppIconImage(packageName: packageName, size: 24), ).then((palette) async { - final color = palette.dominantColor?.color ?? (await super.color(context)); + // `dominantColor` is most representative but can have low contrast with a dark background + // `vibrantColor` is usually representative and has good contrast with a dark background + final color = palette.vibrantColor?.color ?? (await super.color(context)); _appColors[album] = color; return color; }); diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 0ffb1b86a..5942153a0 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -31,6 +31,8 @@ abstract class CollectionFilter extends Equatable implements Comparable) { diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 01b9b1b71..3f593b7cc 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -58,15 +59,17 @@ class LocationFilter extends CollectionFilter { @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { - final flag = countryCodeToFlag(_countryCode); - // as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates, - // not filled with the shadow color as expected, so we remove them - if (flag != null) { - return Text( - flag, - style: TextStyle(fontSize: size, shadows: const []), - textScaleFactor: 1.0, - ); + if (_countryCode != null && device.canRenderFlagEmojis) { + final flag = countryCodeToFlag(_countryCode); + // as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates, + // not filled with the shadow color as expected, so we remove them + if (flag != null) { + return Text( + flag, + style: TextStyle(fontSize: size, shadows: const []), + textScaleFactor: 1.0, + ); + } } return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size); } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 1f5c1e0dc..f525ccdb1 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter { TagFilter(this.tag) { if (tag.isEmpty) { - _test = (entry) => entry.xmpSubjects.isEmpty; + _test = (entry) => entry.tags.isEmpty; } else { - _test = (entry) => entry.xmpSubjects.contains(tag); + _test = (entry) => entry.tags.contains(tag); } } diff --git a/lib/model/metadata/enums.dart b/lib/model/metadata/enums.dart index 351d441ec..145ce2ea7 100644 --- a/lib/model/metadata/enums.dart +++ b/lib/model/metadata/enums.dart @@ -13,6 +13,8 @@ enum DateEditAction { } enum MetadataType { + // JPEG COM marker or GIF comment + comment, // Exif: https://en.wikipedia.org/wiki/Exif exif, // ICC profile: https://en.wikipedia.org/wiki/ICC_profile @@ -23,8 +25,6 @@ enum MetadataType { jfif, // JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe jpegAdobe, - // JPEG COM marker - jpegComment, // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky jpegDucky, // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ @@ -42,6 +42,7 @@ class MetadataTypes { static const common = { MetadataType.exif, MetadataType.xmp, + MetadataType.comment, MetadataType.iccProfile, MetadataType.iptc, MetadataType.photoshopIrb, @@ -50,7 +51,6 @@ class MetadataTypes { static const jpeg = { MetadataType.jfif, MetadataType.jpegAdobe, - MetadataType.jpegComment, MetadataType.jpegDucky, }; } @@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType { // match `ExifInterface` directory names String getText() { switch (this) { + case MetadataType.comment: + return 'Comment'; case MetadataType.exif: return 'Exif'; case MetadataType.iccProfile: @@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType { return 'JFIF'; case MetadataType.jpegAdobe: return 'Adobe JPEG'; - case MetadataType.jpegComment: - return 'JpegComment'; case MetadataType.jpegDucky: return 'Ducky'; case MetadataType.photoshopIrb: diff --git a/lib/model/metadata/overlay.dart b/lib/model/metadata/overlay.dart index bebcb6e1c..9dc983af3 100644 --- a/lib/model/metadata/overlay.dart +++ b/lib/model/metadata/overlay.dart @@ -1,20 +1,23 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; -class OverlayMetadata { - final String? aperture, exposureTime, focalLength, iso; +@immutable +class OverlayMetadata extends Equatable { + final double? aperture, focalLength; + final String? exposureTime; + final int? iso; - static final apertureFormat = NumberFormat('0.0', 'en_US'); - static final focalLengthFormat = NumberFormat('0.#', 'en_US'); + @override + List get props => [aperture, exposureTime, focalLength, iso]; - OverlayMetadata({ - double? aperture, + bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; + + const OverlayMetadata({ + this.aperture, this.exposureTime, - double? focalLength, - int? iso, - }) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, - focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null, - iso = iso != null ? 'ISO$iso' : null; + this.focalLength, + this.iso, + }); factory OverlayMetadata.fromMap(Map map) { return OverlayMetadata( @@ -24,9 +27,4 @@ class OverlayMetadata { iso: map['iso'] as int?, ); } - - bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; - - @override - String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index df3750b5b..bb0a63201 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -20,7 +20,7 @@ abstract class MetadataDb { Future reset(); - Future removeIds(Set contentIds, {required bool metadataOnly}); + Future removeIds(Set contentIds, {Set? dataTypes}); // entries @@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeIds(Set contentIds, {required bool metadataOnly}) async { + Future removeIds(Set contentIds, {Set? dataTypes}) async { if (contentIds.isEmpty) return; + final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); + final db = await _database; // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead final batch = db.batch(); const where = 'contentId = ?'; contentIds.forEach((id) { final whereArgs = [id]; - batch.delete(entryTable, where: where, whereArgs: whereArgs); - batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); - batch.delete(metadataTable, where: where, whereArgs: whereArgs); - batch.delete(addressTable, where: where, whereArgs: whereArgs); - if (!metadataOnly) { + if (_dataTypes.contains(EntryDataType.basic)) { + batch.delete(entryTable, where: where, whereArgs: whereArgs); + } + if (_dataTypes.contains(EntryDataType.catalog)) { + batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); + batch.delete(metadataTable, where: where, whereArgs: whereArgs); + } + if (_dataTypes.contains(EntryDataType.address)) { + batch.delete(addressTable, where: where, whereArgs: whereArgs); + } + if (_dataTypes.contains(EntryDataType.references)) { batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs); batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs); diff --git a/lib/model/query.dart b/lib/model/query.dart index 7bb5c5241..c1c510a6d 100644 --- a/lib/model/query.dart +++ b/lib/model/query.dart @@ -4,6 +4,13 @@ import 'package:aves/utils/change_notifier.dart'; import 'package:flutter/foundation.dart'; class Query extends ChangeNotifier { + Query({required String? initialValue}) { + if (initialValue != null && initialValue.isNotEmpty) { + _enabled = true; + queryNotifier.value = initialValue; + } + } + bool _enabled = false; bool get enabled => _enabled; diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index 5a65e6818..4a47c50ae 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -2,6 +2,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:intl/intl.dart'; import 'package:latlong2/latlong.dart'; import 'enums.dart'; @@ -16,24 +17,36 @@ extension ExtraCoordinateFormat on CoordinateFormat { } } + static const _separator = ', '; + String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) { switch (this) { case CoordinateFormat.dms: - return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', '); + return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(_separator); case CoordinateFormat.decimal: - return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); + return _toDecimal(l10n, latLng).join(_separator); } } // 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 locale = l10n.localeName; final lat = latLng.latitude; final lng = latLng.longitude; - final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals); - final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals); + final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals, locale); + final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals, locale); return [ l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth), l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast), ]; } + + static List _toDecimal(AppLocalizations l10n, LatLng latLng) { + final locale = l10n.localeName; + final formatter = NumberFormat('0.000000°', locale); + return [ + formatter.format(latLng.latitude), + formatter.format(latLng.longitude), + ]; + } } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 47db03e03..b7f37b315 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -65,6 +65,7 @@ class SettingsDefaults { static const showOverlayShootingDetails = false; static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value static const viewerUseCutout = true; + static const viewerMaxBrightness = false; // video static const videoQuickActions = [ @@ -98,4 +99,7 @@ class SettingsDefaults { // accessibility static const accessibilityAnimations = AccessibilityAnimations.system; static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value + + // file picker + static const filePickerShowHiddenFiles = false; } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 62f76fd1c..a70d23ad2 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -81,6 +81,7 @@ class Settings extends ChangeNotifier { static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect'; static const viewerUseCutoutKey = 'viewer_use_cutout'; + static const viewerMaxBrightnessKey = 'viewer_max_brightness'; // video static const videoQuickActionsKey = 'video_quick_actions'; @@ -116,6 +117,9 @@ class Settings extends ChangeNotifier { // version static const lastVersionCheckDateKey = 'last_version_check_date'; + // file picker + static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files'; + // platform settings // cf Android `Settings.System.ACCELEROMETER_ROTATION` static const platformAccelerometerRotationKey = 'accelerometer_rotation'; @@ -152,8 +156,8 @@ class Settings extends ChangeNotifier { enableOverlayBlurEffect = performanceClass >= 29; // availability - final hasPlayServices = await availability.hasPlayServices; - if (hasPlayServices) { + final canUseGoogleMaps = await availability.canUseGoogleMaps; + if (canUseGoogleMaps) { infoMapStyle = EntryMapStyle.googleNormal; } else { final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList(); @@ -352,6 +356,10 @@ class Settings extends ChangeNotifier { set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue); + bool get viewerMaxBrightness => getBoolOrDefault(viewerMaxBrightnessKey, SettingsDefaults.viewerMaxBrightness); + + set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue); + // video List get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values); @@ -446,6 +454,12 @@ class Settings extends ChangeNotifier { set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch); + // file picker + + bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles); + + set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue); + // convenience methods // ignore: avoid_positional_boolean_parameters @@ -587,10 +601,12 @@ class Settings extends ChangeNotifier { case showOverlayShootingDetailsKey: case enableOverlayBlurEffectKey: case viewerUseCutoutKey: + case viewerMaxBrightnessKey: case enableVideoHardwareAccelerationKey: case enableVideoAutoPlayKey: case subtitleShowOutlineKey: case saveSearchHistoryKey: + case filePickerShowHiddenFilesKey: if (value is bool) { _prefs!.setBool(key, value); } else { diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 6f05c03b3..59166c762 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; +import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; @@ -10,6 +11,7 @@ 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/events.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/tag.dart'; @@ -49,7 +51,12 @@ class CollectionLens with ChangeNotifier { final sourceEvents = source.eventBus; _subscriptions.add(sourceEvents.on().listen((e) => _onEntryAdded(e.entries))); _subscriptions.add(sourceEvents.on().listen((e) => _onEntryRemoved(e.entries))); - _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); + _subscriptions.add(sourceEvents.on().listen((e) { + if (e.type == MoveType.move) { + // refreshing copied items is already handled via `EntryAddedEvent`s + _refresh(); + } + })); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on().listen((e) => _refresh())); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 2a74801c3..978f9957b 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; @@ -11,6 +12,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/services/analysis_service.dart'; @@ -104,7 +106,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM _rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId)); } - entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]); + entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) { + entry.catalogDateMillis = _savedDates[entry.contentId]; + }); _entryById.addAll(newIdMapEntries); _rawEntries.addAll(entries); @@ -183,8 +187,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return; } await _moveEntry(entry, newFields, persist: persist); - entry.metadataChangeNotifier.notifyListeners(); - eventBus.fire(EntryMovedEvent({entry})); + entry.metadataChangeNotifier.notify(); + eventBus.fire(EntryMovedEvent(MoveType.move, {entry})); completer.complete(true); }, ); @@ -245,6 +249,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM title: newFields['title'] as String?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?, )); + } else { + debugPrint('failed to find source entry with uri=$sourceUri'); } }); await metadataDb.saveEntries(movedEntries); @@ -273,7 +279,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } invalidateAlbumFilterSummary(directories: fromAlbums); _invalidate(movedEntries); - eventBus.fire(EntryMovedEvent(movedEntries)); + eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries)); } bool get initialized => false; @@ -284,8 +290,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future> refreshUris(Set changedUris, {AnalysisController? analysisController}); - Future refreshEntry(AvesEntry entry) async { - await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale); + Future refreshEntry(AvesEntry entry, Set dataTypes) async { + await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); updateDerivedFilters({entry}); eventBus.fire(EntryRefreshedEvent({entry})); } @@ -381,46 +387,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } } - -@immutable -class EntryAddedEvent { - final Set? entries; - - const EntryAddedEvent([this.entries]); -} - -@immutable -class EntryRemovedEvent { - final Set entries; - - const EntryRemovedEvent(this.entries); -} - -@immutable -class EntryMovedEvent { - final Set entries; - - const EntryMovedEvent(this.entries); -} - -@immutable -class EntryRefreshedEvent { - final Set entries; - - const EntryRefreshedEvent(this.entries); -} - -@immutable -class FilterVisibilityChangedEvent { - final Set filters; - final bool visible; - - const FilterVisibilityChangedEvent(this.filters, this.visible); -} - -@immutable -class ProgressEvent { - final int done, total; - - const ProgressEvent({required this.done, required this.total}); -} diff --git a/lib/model/source/events.dart b/lib/model/source/events.dart new file mode 100644 index 000000000..582a86c2f --- /dev/null +++ b/lib/model/source/events.dart @@ -0,0 +1,48 @@ +import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class EntryAddedEvent { + final Set? entries; + + const EntryAddedEvent([this.entries]); +} + +@immutable +class EntryRemovedEvent { + final Set entries; + + const EntryRemovedEvent(this.entries); +} + +@immutable +class EntryMovedEvent { + final MoveType type; + final Set entries; + + const EntryMovedEvent(this.type, this.entries); +} + +@immutable +class EntryRefreshedEvent { + final Set entries; + + const EntryRefreshedEvent(this.entries); +} + +@immutable +class FilterVisibilityChangedEvent { + final Set filters; + final bool visible; + + const FilterVisibilityChangedEvent(this.filters, this.visible); +} + +@immutable +class ProgressEvent { + final int done, total; + + const ProgressEvent({required this.done, required this.total}); +} diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index d2e246c35..0a88b12d2 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); - await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false); + await metadataDb.removeIds(obsoleteContentIds); // verify paths because some apps move files without updating their `last modified date` debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 3617b0212..121371d63 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -38,7 +38,7 @@ mixin TagMixin on SourceBase { var stopCheckCount = 0; final newMetadata = {}; for (final entry in todo) { - await entry.catalog(background: true, persist: true, force: force); + await entry.catalog(background: true, force: force, persist: true); if (entry.isCatalogued) { newMetadata.add(entry.catalogMetadata!); if (newMetadata.length >= commitCountThreshold) { @@ -63,7 +63,7 @@ mixin TagMixin on SourceBase { } void updateTags() { - final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase); + final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase); if (!listEquals(updatedTags, sortedTags)) { sortedTags = List.unmodifiable(updatedTags); invalidateTagFilterSummary(); @@ -85,7 +85,7 @@ mixin TagMixin on SourceBase { _filterEntryCountMap.clear(); _filterRecentEntryMap.clear(); } else { - tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet(); + tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet(); tags.forEach(_filterEntryCountMap.remove); } eventBus.fire(TagSummaryInvalidatedEvent(tags)); diff --git a/lib/model/video/metadata.dart b/lib/model/video/metadata.dart index 55fdbe94b..be38b825d 100644 --- a/lib/model/video/metadata.dart +++ b/lib/model/video/metadata.dart @@ -81,7 +81,9 @@ class VideoMetadataFormatter { static Future getCatalogMetadata(AvesEntry entry) async { final mediaInfo = await getVideoMetadata(entry); - bool isDefined(dynamic value) => value is String && value != '0'; + // only consider values with at least 8 characters (yyyymmdd), + // ignoring unset values like `0`, as well as year values like `2021` + bool isDefined(dynamic value) => value is String && value.length >= 8; var dateString = mediaInfo[Keys.date]; if (!isDefined(dateString)) { @@ -112,6 +114,7 @@ class VideoMetadataFormatter { // `DateTime` does not recognize: // - `UTC 2021-05-30 19:14:21` + // - `2021` final match = _anotherDatePattern.firstMatch(dateString); if (match != null) { @@ -371,7 +374,7 @@ class VideoMetadataFormatter { static String _formatFilesize(String value) { final size = int.tryParse(value); - return size != null ? formatFilesize(size) : value; + return size != null ? formatFileSize('en_US', size) : value; } static String _formatLanguage(String value) { @@ -396,20 +399,10 @@ class VideoMetadataFormatter { if (parsed == null) return size; size = parsed; } + const divider = 1000; - if (size < divider) return '$size $unit'; - - if (size < divider * divider && size % divider == 0) { - return '${(size / divider).toStringAsFixed(0)} K$unit'; - } - if (size < divider * divider) { - return '${(size / divider).toStringAsFixed(round)} K$unit'; - } - - if (size < divider * divider * divider && size % divider == 0) { - return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit'; - } + if (size < divider * divider) return '${(size / divider).toStringAsFixed(round)} K$unit'; return '${(size / divider / divider).toStringAsFixed(round)} M$unit'; } } diff --git a/lib/ref/iptc.dart b/lib/ref/iptc.dart new file mode 100644 index 000000000..908f18914 --- /dev/null +++ b/lib/ref/iptc.dart @@ -0,0 +1,6 @@ +class IPTC { + static const int applicationRecord = 2; + + // ApplicationRecord tags + static const int keywordsTag = 25; +} \ No newline at end of file diff --git a/lib/services/analysis_service.dart b/lib/services/analysis_service.dart index 354068e6f..10a338886 100644 --- a/lib/services/analysis_service.dart +++ b/lib/services/analysis_service.dart @@ -8,8 +8,8 @@ import 'package:aves/model/source/media_store_source.dart'; import 'package:aves/model/source/source_state.dart'; import 'package:aves/services/common/services.dart'; import 'package:fijkplayer/fijkplayer.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class AnalysisService { diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index a4685b1de..b78690476 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; @@ -29,8 +28,6 @@ abstract class AndroidAppService { Future shareSingle(String uri, String mimeType); - Future canPinToHomeScreen(); - Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? uri}); } @@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService { // app shortcuts - // this ability will not change over the lifetime of the app - bool? _canPin; - - @override - Future canPinToHomeScreen() async { - if (_canPin != null) return SynchronousFuture(_canPin!); - - try { - final result = await platform.invokeMethod('canPin'); - if (result != null) { - _canPin = result; - return result; - } - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return false; - } - @override Future pinToHomeScreen(String label, AvesEntry? coverEntry, {Set? filters, String? uri}) async { Uint8List? iconBytes; @@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService { ); } try { - await platform.invokeMethod('pin', { + await platform.invokeMethod('pinShortcut', { 'label': label, 'iconBytes': iconBytes, 'filters': filters?.map((filter) => filter.toJson()).toList(), diff --git a/lib/services/common/output_buffer.dart b/lib/services/common/output_buffer.dart index 7d7088d3a..e64f04606 100644 --- a/lib/services/common/output_buffer.dart +++ b/lib/services/common/output_buffer.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -// cf flutter/foundation `consolidateHttpClientResponseBytes` +// adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart` class OutputBuffer extends ByteConversionSinkBase { List>? _chunks = >[]; int _contentLength = 0; @@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase { return; } _bytes = Uint8List(_contentLength); - var offset = 0; - for (final chunk in _chunks!) { + int offset = 0; + for (final List chunk in _chunks!) { _bytes!.setRange(offset, offset + chunk.length, chunk); offset += chunk.length; } diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index cacbc5882..1f08e9baf 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart'; import 'package:flutter/services.dart'; abstract class DeviceService { + Future> getCapabilities(); + Future getDefaultTimeZone(); Future getPerformanceClass(); @@ -10,6 +12,17 @@ abstract class DeviceService { class PlatformDeviceService implements DeviceService { static const platform = MethodChannel('deckers.thibault/aves/device'); + @override + Future> getCapabilities() async { + try { + final result = await platform.invokeMethod('getCapabilities'); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return {}; + } + @override Future getDefaultTimeZone() async { try { diff --git a/lib/services/media/enums.dart b/lib/services/media/enums.dart index 7ffed6cf4..2f52e69c3 100644 --- a/lib/services/media/enums.dart +++ b/lib/services/media/enums.dart @@ -1,11 +1,13 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; // names should match possible values on platform enum NameConflictStrategy { rename, replace, skip } extension ExtraNameConflictStrategy on NameConflictStrategy { - String toPlatform() => toString().substring('NameConflictStrategy.'.length); + // TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name` + String toPlatform() => describeEnum(this); String getName(BuildContext context) { switch (this) { diff --git a/lib/services/media/media_file_service.dart b/lib/services/media/media_file_service.dart index c2dc44a4d..ec0a941da 100644 --- a/lib/services/media/media_file_service.dart +++ b/lib/services/media/media_file_service.dart @@ -159,7 +159,7 @@ class PlatformMediaFileService implements MediaFileService { int? pageId, int? expectedContentLength, BytesReceivedCallback? onBytesReceived, - }) { + }) async { try { final completer = Completer.sync(); final sink = OutputBuffer(); @@ -191,11 +191,12 @@ class PlatformMediaFileService implements MediaFileService { }, cancelOnError: true, ); - return completer.future; + // `await` here, so that `completeError` will be caught below + return await completer.future; } on PlatformException catch (e, stack) { - reportService.recordError(e, stack); + await reportService.recordError(e, stack); } - return Future.sync(() => Uint8List(0)); + return Uint8List(0); } @override diff --git a/lib/services/metadata/metadata_edit_service.dart b/lib/services/metadata/metadata_edit_service.dart index cde075fdc..0bb1cfaa4 100644 --- a/lib/services/metadata/metadata_edit_service.dart +++ b/lib/services/metadata/metadata_edit_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/enums.dart'; import 'package:aves/services/common/services.dart'; @@ -13,6 +14,10 @@ abstract class MetadataEditService { Future> editDate(AvesEntry entry, DateModifier modifier); + Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}); + + Future> setXmp(AvesEntry entry, AvesXmp? xmp); + Future> removeTypes(AvesEntry entry, Set types); } @@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService { return {}; } + @override + Future> setIptc(AvesEntry entry, List>? iptc, {required bool postEditScan}) async { + try { + final result = await platform.invokeMethod('setIptc', { + 'entry': _toPlatformEntryMap(entry), + 'iptc': iptc, + 'postEditScan': postEditScan, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return {}; + } + + @override + Future> setXmp(AvesEntry entry, AvesXmp? xmp) async { + try { + final result = await platform.invokeMethod('setXmp', { + 'entry': _toPlatformEntryMap(entry), + 'xmp': xmp?.xmpString, + 'extendedXmp': xmp?.extendedXmpString, + }); + if (result != null) return (result as Map).cast(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return {}; + } + @override Future> removeTypes(AvesEntry entry, Set types) async { try { @@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService { String _toPlatformMetadataType(MetadataType type) { switch (type) { + case MetadataType.comment: + return 'comment'; case MetadataType.exif: return 'exif'; case MetadataType.iccProfile: @@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService { return 'jfif'; case MetadataType.jpegAdobe: return 'jpeg_adobe'; - case MetadataType.jpegComment: - return 'jpeg_comment'; case MetadataType.jpegDucky: return 'jpeg_ducky'; case MetadataType.photoshopIrb: diff --git a/lib/services/metadata/metadata_fetch_service.dart b/lib/services/metadata/metadata_fetch_service.dart index 942c7aa5b..4ed8210d9 100644 --- a/lib/services/metadata/metadata_fetch_service.dart +++ b/lib/services/metadata/metadata_fetch_service.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/multipage.dart'; @@ -20,6 +21,10 @@ abstract class MetadataFetchService { Future getPanoramaInfo(AvesEntry entry); + Future>?> getIptc(AvesEntry entry); + + Future getXmp(AvesEntry entry); + Future hasContentResolverProp(String prop); Future getContentResolverProp(AvesEntry entry, String prop); @@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService { return null; } + @override + Future>?> getIptc(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getIptc', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + }); + if (result != null) return (result as List).cast().map((fields) => fields.cast()).toList(); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } + + @override + Future getXmp(AvesEntry entry) async { + try { + final result = await platform.invokeMethod('getXmp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + }); + if (result != null) return AvesXmp.fromList((result as List).cast()); + } on PlatformException catch (e, stack) { + if (!entry.isMissingAtPath) { + await reportService.recordError(e, stack); + } + } + return null; + } + final Map _contentResolverProps = {}; @override diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 529623a39..4b3666e5f 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -37,8 +37,6 @@ abstract class StorageService { Future createFile(String name, String mimeType, Uint8List bytes); Future openFile(String mimeType); - - Future selectDirectory(); } class PlatformStorageService implements StorageService { @@ -174,7 +172,8 @@ class PlatformStorageService implements StorageService { }, cancelOnError: true, ); - return completer.future; + // `await` here, so that `completeError` will be caught below + return await completer.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } @@ -198,7 +197,8 @@ class PlatformStorageService implements StorageService { }, cancelOnError: true, ); - return completer.future; + // `await` here, so that `completeError` will be caught below + return await completer.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } @@ -222,7 +222,8 @@ class PlatformStorageService implements StorageService { }, cancelOnError: true, ); - return completer.future; + // `await` here, so that `completeError` will be caught below + return await completer.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } @@ -249,31 +250,11 @@ class PlatformStorageService implements StorageService { }, cancelOnError: true, ); - return completer.future; + // `await` here, so that `completeError` will be caught below + return await completer.future; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return Uint8List(0); } - - @override - Future selectDirectory() async { - try { - final completer = Completer(); - storageAccessChannel.receiveBroadcastStream({ - 'op': 'selectDirectory', - }).listen( - (data) => completer.complete(data as String?), - onError: completer.completeError, - onDone: () { - if (!completer.isCompleted) completer.complete(null); - }, - cancelOnError: true, - ); - return completer.future; - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return null; - } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index ecba948fa..b071be57c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -46,6 +46,7 @@ class Durations { // info animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300); + static const tagEditorTransition = Duration(milliseconds: 200); // settings animations static const quickActionListAnimation = Duration(milliseconds: 200); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 421714a50..b54225f18 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -14,11 +14,13 @@ class AIcons { static const IconData date = Icons.calendar_today_outlined; static const IconData disc = Icons.fiber_manual_record; static const IconData error = Icons.error_outline; + static const IconData folder = Icons.folder_outlined; static const IconData grid = Icons.grid_on_outlined; static const IconData home = Icons.home_outlined; static const IconData language = Icons.translate_outlined; static const IconData location = Icons.place_outlined; static const IconData locationOff = Icons.location_off_outlined; + static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; @@ -33,6 +35,7 @@ class AIcons { // actions static const IconData add = Icons.add_circle_outline; static const IconData addShortcut = Icons.add_to_home_screen_outlined; + static const IconData addTag = MdiIcons.tagPlusOutline; static const IconData replay10 = Icons.replay_10_outlined; static const IconData skip10 = Icons.forward_10_outlined; static const IconData captureFrame = Icons.screenshot_outlined; @@ -66,6 +69,7 @@ class AIcons { static const IconData print = Icons.print_outlined; static const IconData refresh = Icons.refresh_outlined; static const IconData rename = Icons.title_outlined; + static const IconData reset = Icons.restart_alt_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index a81e03bb8..c0de12a18 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -32,10 +32,10 @@ class AndroidFileUtils { downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); picturesPath = pContext.join(primaryStorage, 'Pictures'); - avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures'); + avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures'); videoCapturesPaths = { // from Samsung - pContext.join(dcimPath, 'Video Captures'), + pContext.join(dcimPath, 'Videocaptures'), // from Aves avesVideoCapturesPath, }; diff --git a/lib/utils/change_notifier.dart b/lib/utils/change_notifier.dart index 2a1eddca8..60ad5d939 100644 --- a/lib/utils/change_notifier.dart +++ b/lib/utils/change_notifier.dart @@ -1,26 +1,9 @@ import 'package:flutter/foundation.dart'; -// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin -class AChangeNotifier implements Listenable { - ObserverList? _listeners = ObserverList(); - - @override - void addListener(VoidCallback listener) => _listeners!.add(listener); - - @override - void removeListener(VoidCallback listener) => _listeners!.remove(listener); - - void dispose() => _listeners = null; - - void notifyListeners() { - if (_listeners == null) return; - final localListeners = List.from(_listeners!); - for (final listener in localListeners) { - try { - if (_listeners!.contains(listener)) listener(); - } catch (error, stack) { - debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack'); - } - } +// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin +class AChangeNotifier extends ChangeNotifier { + void notify() { + // why is this protected? + super.notifyListeners(); } } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 1a7bc6d45..70c58b324 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -132,6 +132,11 @@ class Constants { license: 'Apache 2.0', sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), + Dependency( + name: 'Screen Brightness', + license: 'MIT', + sourceUrl: 'https://github.com/aaassseee/screen_brightness', + ), Dependency( name: 'Shared Preferences', license: 'BSD 3-Clause', diff --git a/lib/utils/file_utils.dart b/lib/utils/file_utils.dart index 50e1319df..ca707920f 100644 --- a/lib/utils/file_utils.dart +++ b/lib/utils/file_utils.dart @@ -1,38 +1,16 @@ -String formatFilesize(int size, {int round = 2}) { - var divider = 1024; +import 'package:intl/intl.dart'; - if (size < divider) return '$size B'; +const _kiloDivider = 1024; +const _megaDivider = _kiloDivider * _kiloDivider; +const _gigaDivider = _megaDivider * _kiloDivider; +const _teraDivider = _gigaDivider * _kiloDivider; - if (size < divider * divider && size % divider == 0) { - return '${(size / divider).toStringAsFixed(0)} KB'; - } - if (size < divider * divider) { - return '${(size / divider).toStringAsFixed(round)} KB'; - } +String formatFileSize(String locale, int size, {int round = 2}) { + if (size < _kiloDivider) return '$size B'; - if (size < divider * divider * divider && size % divider == 0) { - return '${(size / (divider * divider)).toStringAsFixed(0)} MB'; - } - if (size < divider * divider * divider) { - return '${(size / divider / divider).toStringAsFixed(round)} MB'; - } - - if (size < divider * divider * divider * divider && size % divider == 0) { - return '${(size / (divider * divider * divider)).toStringAsFixed(0)} GB'; - } - if (size < divider * divider * divider * divider) { - return '${(size / divider / divider / divider).toStringAsFixed(round)} GB'; - } - - if (size < divider * divider * divider * divider * divider && size % divider == 0) { - return '${(size / divider / divider / divider / divider).toStringAsFixed(0)} TB'; - } - if (size < divider * divider * divider * divider * divider) { - return '${(size / divider / divider / divider / divider).toStringAsFixed(round)} TB'; - } - - if (size < divider * divider * divider * divider * divider * divider && size % divider == 0) { - return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(0)} PB'; - } - return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(round)} PB'; + final formatter = NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale); + if (size < _megaDivider) return '${formatter.format(size / _kiloDivider)} KB'; + if (size < _gigaDivider) return '${formatter.format(size / _megaDivider)} MB'; + if (size < _teraDivider) return '${formatter.format(size / _gigaDivider)} GB'; + return '${formatter.format(size / _teraDivider)} TB'; } diff --git a/lib/utils/geo_utils.dart b/lib/utils/geo_utils.dart index 7316abf5f..f8ca04e33 100644 --- a/lib/utils/geo_utils.dart +++ b/lib/utils/geo_utils.dart @@ -1,33 +1,23 @@ import 'dart:math'; -import 'package:aves/utils/math_utils.dart'; import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; class GeoUtils { - 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) - final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); - return [ - int.parse(tmp[0]).abs(), - int.parse(tmp[1]), - ]; - } - - final deg = _split(degDecimal)[0]; - final minDecimal = (degDecimal.abs() - deg) * 60; - final min = _split(minDecimal)[0]; + static String decimal2sexagesimal( + double degDecimal, + bool minuteSecondPadding, + int secondDecimals, + String locale, + ) { + final degAbs = degDecimal.abs(); + final deg = degAbs.toInt(); + final minDecimal = (degAbs - deg) * 60; + final min = minDecimal.toInt(); final sec = (minDecimal - min) * 60; - final secRounded = roundToPrecision(sec, decimals: secondDecimals); - var minText = '$min'; - var secText = secRounded.toStringAsFixed(secondDecimals); - if (minuteSecondPadding) { - minText = minText.padLeft(2, '0'); - secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0'); - } + var minText = NumberFormat('0' * (minuteSecondPadding ? 2 : 1), locale).format(min); + var secText = NumberFormat('${'0' * (minuteSecondPadding ? 2 : 1)}${secondDecimals > 0 ? '.${'0' * secondDecimals}' : ''}', locale).format(sec); return '$deg° $minText′ $secText″'; } diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index 3bc167ca4..4364adb92 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -8,12 +8,13 @@ import 'package:flutter/material.dart'; class AboutCredits extends StatelessWidget { const AboutCredits({Key? key}) : super(key: key); - static const translations = [ - 'Русский: D3ZOXY', - ]; + static const translators = { + 'Русский': 'D3ZOXY', + }; @override Widget build(BuildContext context) { + final l10n = context.l10n; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( @@ -23,13 +24,13 @@ class AboutCredits extends StatelessWidget { constraints: const BoxConstraints(minHeight: 48), child: Align( alignment: AlignmentDirectional.centerStart, - child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle), + child: Text(l10n.aboutCredits, style: Constants.titleTextStyle), ), ), Text.rich( TextSpan( children: [ - TextSpan(text: context.l10n.aboutCreditsWorldAtlas1), + TextSpan(text: l10n.aboutCreditsWorldAtlas1), const WidgetSpan( child: LinkChip( text: 'World Atlas', @@ -38,17 +39,19 @@ class AboutCredits extends StatelessWidget { ), alignment: PlaceholderAlignment.middle, ), - TextSpan(text: context.l10n.aboutCreditsWorldAtlas2), + TextSpan(text: l10n.aboutCreditsWorldAtlas2), ], ), ), const SizedBox(height: 16), - Text(context.l10n.aboutCreditsTranslators), - ...translations.map( - (line) => Padding( - padding: const EdgeInsetsDirectional.only(start: 8, top: 8), - child: Text(line), - ), + Text(l10n.aboutCreditsTranslators), + ...translators.entries.map( + (kv) { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 8, top: 8), + child: Text(l10n.aboutCreditsTranslatorLine(kv.key, kv.value)), + ); + }, ), const SizedBox(height: 16), ], diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 2518042d3..607211839 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -1,7 +1,9 @@ +import 'dart:async'; import 'dart:ui'; import 'package:aves/app_flavor.dart'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; @@ -161,6 +163,7 @@ class _AvesAppState extends State { isRotationLocked: await windowService.isRotationLocked(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), ); + await device.init(); FijkLog.setLevel(FijkLogLevel.Warn); // keep screen on diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 5135feec9..1df527bc0 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -10,7 +10,6 @@ import 'package:aves/model/settings/settings.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/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; @@ -46,7 +45,6 @@ class _CollectionAppBarState extends State with SingleTickerPr 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; @@ -69,7 +67,6 @@ class _CollectionAppBarState extends State with SingleTickerPr vsync: this, ); _isSelectingNotifier.addListener(_onActivityChange); - _canAddShortcutsLoader = androidAppService.canPinToHomeScreen(); _registerWidget(widget); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); } @@ -104,53 +101,46 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { final appMode = context.watch>().value; - 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, - ); - }, + 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, + ), + 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, ); }, ); @@ -214,14 +204,12 @@ class _CollectionAppBarState extends State with SingleTickerPr List _buildActions({ required bool isSelecting, required int selectedItemCount, - required bool supportShortcuts, }) { final appMode = context.watch>().value; bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( action, appMode: appMode, isSelecting: isSelecting, - supportShortcuts: supportShortcuts, sortFactor: collection.sortFactor, itemCount: collection.entryCount, selectedItemCount: selectedItemCount, @@ -269,6 +257,7 @@ class _CollectionAppBarState extends State with SingleTickerPr _buildRotateAndFlipMenuItems(context, canApply: canApply), ...[ EntrySetAction.editDate, + EntrySetAction.editTags, EntrySetAction.removeMetadata, ].map((action) => _toMenuItem(action, enabled: canApply(action))), ], @@ -295,7 +284,8 @@ class _CollectionAppBarState extends State with SingleTickerPr } // 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)}'); + // TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name` + Key _getActionKey(EntrySetAction action) => Key('menu-${describeEnum(action)}'); Widget _toActionButton(EntrySetAction action, {required bool enabled}) { final onPressed = enabled ? () => _onActionSelected(action) : null; @@ -439,6 +429,7 @@ class _CollectionAppBarState extends State with SingleTickerPr case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editTags: case EntrySetAction.removeMetadata: _actionDelegate.onActionSelected(context, action); break; diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 20d8607e2..94dcd625a 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,4 +1,5 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; @@ -8,6 +9,7 @@ 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:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -37,10 +39,12 @@ class _CollectionPageState extends State { @override Widget build(BuildContext context) { + final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( child: QueryProvider( + initialQuery: liveFilter?.query, child: Builder( builder: (context) => WillPopScope( onWillPop: () { diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index 089ddf604..b96b7e599 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -3,6 +3,7 @@ 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/utils/file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/draggable_thumb_label.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:flutter/material.dart'; @@ -48,7 +49,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget { ]; case EntrySortFactor.size: return [ - if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0), + if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0), ]; } }, diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index d8d5029af..3537fbb6b 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -4,7 +4,9 @@ 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/device.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; @@ -43,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa EntrySetAction action, { required AppMode appMode, required bool isSelecting, - required bool supportShortcuts, required EntrySortFactor sortFactor, required int itemCount, required int selectedItemCount, @@ -66,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.toggleTitleSearch: return !isSelecting; case EntrySetAction.addShortcut: - return appMode == AppMode.main && !isSelecting && supportShortcuts; + return appMode == AppMode.main && !isSelecting && device.canPinShortcut; // browsing or selecting case EntrySetAction.map: case EntrySetAction.stats: @@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return appMode == AppMode.main && isSelecting; } @@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.rotateCW: case EntrySetAction.flip: case EntrySetAction.editDate: + case EntrySetAction.editTags: case EntrySetAction.removeMetadata: return hasSelection; } @@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa case EntrySetAction.editDate: _editDate(context); break; + case EntrySetAction.editTags: + _editTags(context); + break; case EntrySetAction.removeMetadata: _removeMetadata(context); break; @@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa BuildContext context, Selection selection, Set todoItems, - Future Function(AvesEntry entry) op, + Future> Function(AvesEntry entry) op, ) async { final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); final todoCount = todoItems.length; @@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa showOpReport( context: context, opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { - final success = await op(entry); - return ImageOpEvent(success: success, uri: entry.uri); + final dataTypes = await op(entry); + return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri); }).asBroadcastStream(), itemCount: todoCount, onDone: (processed) async { @@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa ); if (confirmed == null || !confirmed) return null; + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation); return supported; } @@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif); + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate); if (todoItems == null || todoItems.isEmpty) return; final modifier = await selectDateModifier(context, todoItems); @@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier)); } + Future _editTags(BuildContext context) async { + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags); + if (todoItems == null || todoItems.isEmpty) return; + + final newTagsByEntry = await selectTags(context, todoItems); + if (newTagsByEntry == null) return; + + // only process modified items + todoItems.removeWhere((entry) { + final newTags = newTagsByEntry[entry] ?? entry.tags; + final currentTags = entry.tags; + return newTags.length == currentTags.length && newTags.every(currentTags.contains); + }); + + if (todoItems.isEmpty) return; + + await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!)); + } + Future _removeMetadata(BuildContext context) async { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); @@ -596,6 +626,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa final name = result.item2; if (name.isEmpty) return; - unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters)); + await androidAppService.pinToHomeScreen(name, coverEntry, filters: filters); + if (!device.showPinShortcutFeedback) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } } } diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index 2670b1acf..adc11af73 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -65,8 +65,8 @@ class MonthSectionHeader extends StatelessWidget { if (date == null) return l10n.sectionUnknown; if (date.isThisMonth) return l10n.dateThisMonth; final locale = l10n.localeName; - if (date.isThisYear) return DateFormat.MMMM(locale).format(date); - return DateFormat.yMMMM(locale).format(date); + final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date); + return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}'; } @override diff --git a/lib/widgets/collection/query_bar.dart b/lib/widgets/collection/query_bar.dart index f915b9a49..f12c18b16 100644 --- a/lib/widgets/collection/query_bar.dart +++ b/lib/widgets/collection/query_bar.dart @@ -42,8 +42,6 @@ class _EntryQueryBarState extends State { 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); } diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 3212a9827..2ef66c82f 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -4,8 +4,9 @@ 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:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart'; +import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart'; import 'package:flutter/material.dart'; mixin EntryEditorMixin { @@ -21,6 +22,23 @@ mixin EntryEditorMixin { return modifier; } + Future>?> selectTags(BuildContext context, Set entries) async { + if (entries.isEmpty) return null; + + final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet()))); + await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: TagEditorPage.routeName), + builder: (context) => TagEditorPage( + tagsByEntry: tagsByEntry, + ), + ), + ); + + return tagsByEntry; + } + Future?> selectMetadataToRemove(BuildContext context, Set entries) async { if (entries.isEmpty) return null; diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index e3793e77b..03c4eb7f3 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -75,13 +75,15 @@ mixin SizeAwareMixin { await showDialog( context: context, builder: (context) { - final neededSize = formatFilesize(needed); - final freeSize = formatFilesize(free); + final l10n = context.l10n; + final locale = l10n.localeName; + final neededSize = formatFileSize(locale, needed); + final freeSize = formatFileSize(locale, free); final volume = destinationVolume.getDescription(context); return AvesDialog( context: context, - title: context.l10n.notEnoughSpaceDialogTitle, - content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), + title: l10n.notEnoughSpaceDialogTitle, + content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 4362ee50d..7975f088e 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/source_state.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; diff --git a/lib/widgets/common/aves_highlight.dart b/lib/widgets/common/aves_highlight.dart index 1ae123586..3e29db11e 100644 --- a/lib/widgets/common/aves_highlight.dart +++ b/lib/widgets/common/aves_highlight.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:highlight/highlight.dart' show highlight, Node; -// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6 +// adapted from package `flutter_highlight` v0.7.0 `HighlightView` +// TODO TLAD use the TextSpan getter when this is fixed: https://github.com/git-touch/highlight/issues/6 /// Highlight Flutter Widget class AvesHighlightView extends StatelessWidget { diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index e3892c578..81a651ee5 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /* - This is derived from `draggable_scrollbar` package v0.0.4: + adapted from package `draggable_scrollbar` v0.0.4: - removed default thumb builders - allow any `ScrollView` as child - allow any `Widget` as label content diff --git a/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart b/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart new file mode 100644 index 000000000..38fd4c2ec --- /dev/null +++ b/lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart @@ -0,0 +1,395 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// adapted from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart` +// ignore_for_file: curly_braces_in_flow_control_structures, deprecated_member_use, unnecessary_null_comparison + +/// The possible states of a [ScaleGestureRecognizer]. +enum _ScaleState { + /// The recognizer is ready to start recognizing a gesture. + ready, + + /// The sequence of pointer events seen thus far is consistent with a scale + /// gesture but the gesture has not been accepted definitively. + possible, + + /// The sequence of pointer events seen thus far has been accepted + /// definitively as a scale gesture. + accepted, + + /// The sequence of pointer events seen thus far has been accepted + /// definitively as a scale gesture and the pointers established a focal point + /// and initial scale. + started, +} + +//////////////////////////////////////////////////////////////////////////////// + +bool _isFlingGesture(Velocity velocity) { + assert(velocity != null); + final double speedSquared = velocity.pixelsPerSecond.distanceSquared; + return speedSquared > kMinFlingVelocity * kMinFlingVelocity; +} + +/// Defines a line between two pointers on screen. +/// +/// [_LineBetweenPointers] is an abstraction of a line between two pointers in +/// contact with the screen. Used to track the rotation of a scale gesture. +class _LineBetweenPointers { + /// Creates a [_LineBetweenPointers]. None of the [pointerStartLocation], [pointerStartId] + /// [pointerEndLocation] and [pointerEndId] must be null. [pointerStartId] and [pointerEndId] + /// should be different. + _LineBetweenPointers({ + this.pointerStartLocation = Offset.zero, + this.pointerStartId = 0, + this.pointerEndLocation = Offset.zero, + this.pointerEndId = 1, + }) : assert(pointerStartLocation != null && pointerEndLocation != null), + assert(pointerStartId != null && pointerEndId != null), + assert(pointerStartId != pointerEndId); + + // The location and the id of the pointer that marks the start of the line. + final Offset pointerStartLocation; + final int pointerStartId; + + // The location and the id of the pointer that marks the end of the line. + final Offset pointerEndLocation; + final int pointerEndId; +} + +/// Recognizes a scale gesture. +/// +/// [ScaleGestureRecognizer] tracks the pointers in contact with the screen and +/// calculates their focal point, indicated scale, and rotation. When a focal +/// pointer is established, the recognizer calls [onStart]. As the focal point, +/// scale, rotation change, the recognizer calls [onUpdate]. When the pointers +/// are no longer in contact with the screen, the recognizer calls [onEnd]. +class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer { + /// Create a gesture recognizer for interactions intended for scaling content. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + EagerScaleGestureRecognizer({ + Object? debugOwner, + @Deprecated( + 'Migrate to supportedDevices. ' + 'This feature was deprecated after v2.3.0-1.0.pre.', + ) + PointerDeviceKind? kind, + Set? supportedDevices, + this.dragStartBehavior = DragStartBehavior.down, + }) : assert(dragStartBehavior != null), + super( + debugOwner: debugOwner, + kind: kind, + supportedDevices: supportedDevices, + ); + + /// Determines what point is used as the starting point in all calculations + /// involving this gesture. + /// + /// When set to [DragStartBehavior.down], the scale is calculated starting + /// from the position where the pointer first contacted the screen. + /// + /// When set to [DragStartBehavior.start], the scale is calculated starting + /// from the position where the scale gesture began. The scale gesture may + /// begin after the time that the pointer first contacted the screen if there + /// are multiple listeners competing for the gesture. In that case, the + /// gesture arena waits to determine whether or not the gesture is a scale + /// gesture before giving the gesture to this GestureRecognizer. This happens + /// in the case of nested GestureDetectors, for example. + /// + /// Defaults to [DragStartBehavior.down]. + /// + /// See also: + /// + /// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation, + /// which provides more information about the gesture arena. + DragStartBehavior dragStartBehavior; + + /// The pointers in contact with the screen have established a focal point and + /// initial scale of 1.0. + /// + /// This won't be called until the gesture arena has determined that this + /// GestureRecognizer has won the gesture. + /// + /// See also: + /// + /// * https://flutter.dev/docs/development/ui/advanced/gestures#gesture-disambiguation, + /// which provides more information about the gesture arena. + GestureScaleStartCallback? onStart; + + /// The pointers in contact with the screen have indicated a new focal point + /// and/or scale. + GestureScaleUpdateCallback? onUpdate; + + /// The pointers are no longer in contact with the screen. + GestureScaleEndCallback? onEnd; + + _ScaleState _state = _ScaleState.ready; + + Matrix4? _lastTransform; + + late Offset _initialFocalPoint; + late Offset _currentFocalPoint; + late double _initialSpan; + late double _currentSpan; + late double _initialHorizontalSpan; + late double _currentHorizontalSpan; + late double _initialVerticalSpan; + late double _currentVerticalSpan; + _LineBetweenPointers? _initialLine; + _LineBetweenPointers? _currentLine; + late Map _pointerLocations; + late List _pointerQueue; // A queue to sort pointers in order of entrance + final Map _velocityTrackers = {}; + + double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0; + + double get _horizontalScaleFactor => _initialHorizontalSpan > 0.0 ? _currentHorizontalSpan / _initialHorizontalSpan : 1.0; + + double get _verticalScaleFactor => _initialVerticalSpan > 0.0 ? _currentVerticalSpan / _initialVerticalSpan : 1.0; + + double _computeRotationFactor() { + if (_initialLine == null || _currentLine == null) { + return 0.0; + } + final double fx = _initialLine!.pointerStartLocation.dx; + final double fy = _initialLine!.pointerStartLocation.dy; + final double sx = _initialLine!.pointerEndLocation.dx; + final double sy = _initialLine!.pointerEndLocation.dy; + + final double nfx = _currentLine!.pointerStartLocation.dx; + final double nfy = _currentLine!.pointerStartLocation.dy; + final double nsx = _currentLine!.pointerEndLocation.dx; + final double nsy = _currentLine!.pointerEndLocation.dy; + + final double angle1 = math.atan2(fy - sy, fx - sx); + final double angle2 = math.atan2(nfy - nsy, nfx - nsx); + + return angle2 - angle1; + } + + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + _velocityTrackers[event.pointer] = VelocityTracker.withKind(event.kind); + if (_state == _ScaleState.ready) { + _state = _ScaleState.possible; + _initialSpan = 0.0; + _currentSpan = 0.0; + _initialHorizontalSpan = 0.0; + _currentHorizontalSpan = 0.0; + _initialVerticalSpan = 0.0; + _currentVerticalSpan = 0.0; + _pointerLocations = {}; + _pointerQueue = []; + } + } + + @override + void handleEvent(PointerEvent event) { + assert(_state != _ScaleState.ready); + bool didChangeConfiguration = false; + bool shouldStartIfAccepted = false; + if (event is PointerMoveEvent) { + final VelocityTracker tracker = _velocityTrackers[event.pointer]!; + if (!event.synthesized) tracker.addPosition(event.timeStamp, event.position); + _pointerLocations[event.pointer] = event.position; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + _pointerQueue.add(event.pointer); + didChangeConfiguration = true; + shouldStartIfAccepted = true; + _lastTransform = event.transform; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointerLocations.remove(event.pointer); + _pointerQueue.remove(event.pointer); + didChangeConfiguration = true; + _lastTransform = event.transform; + } + + _updateLines(); + _update(); + + if (!didChangeConfiguration || _reconfigure(event.pointer)) _advanceStateMachine(shouldStartIfAccepted, event.kind); + stopTrackingIfPointerNoLongerDown(event); + } + + void _update() { + final int count = _pointerLocations.keys.length; + + // Compute the focal point + Offset focalPoint = Offset.zero; + for (final int pointer in _pointerLocations.keys) focalPoint += _pointerLocations[pointer]!; + _currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero; + + // Span is the average deviation from focal point. Horizontal and vertical + // spans are the average deviations from the focal point's horizontal and + // vertical coordinates, respectively. + double totalDeviation = 0.0; + double totalHorizontalDeviation = 0.0; + double totalVerticalDeviation = 0.0; + for (final int pointer in _pointerLocations.keys) { + totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]!).distance; + totalHorizontalDeviation += (_currentFocalPoint.dx - _pointerLocations[pointer]!.dx).abs(); + totalVerticalDeviation += (_currentFocalPoint.dy - _pointerLocations[pointer]!.dy).abs(); + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + _currentHorizontalSpan = count > 0 ? totalHorizontalDeviation / count : 0.0; + _currentVerticalSpan = count > 0 ? totalVerticalDeviation / count : 0.0; + } + + /// Updates [_initialLine] and [_currentLine] accordingly to the situation of + /// the registered pointers. + void _updateLines() { + final int count = _pointerLocations.keys.length; + assert(_pointerQueue.length >= count); + + /// In case of just one pointer registered, reconfigure [_initialLine] + if (count < 2) { + _initialLine = _currentLine; + } else if (_initialLine != null && _initialLine!.pointerStartId == _pointerQueue[0] && _initialLine!.pointerEndId == _pointerQueue[1]) { + /// Rotation updated, set the [_currentLine] + _currentLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + } else { + /// A new rotation process is on the way, set the [_initialLine] + _initialLine = _LineBetweenPointers( + pointerStartId: _pointerQueue[0], + pointerStartLocation: _pointerLocations[_pointerQueue[0]]!, + pointerEndId: _pointerQueue[1], + pointerEndLocation: _pointerLocations[_pointerQueue[1]]!, + ); + _currentLine = _initialLine; + } + } + + bool _reconfigure(int pointer) { + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + if (_state == _ScaleState.started) { + if (onEnd != null) { + final VelocityTracker tracker = _velocityTrackers[pointer]!; + + Velocity velocity = tracker.getVelocity(); + if (_isFlingGesture(velocity)) { + final Offset pixelsPerSecond = velocity.pixelsPerSecond; + if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity) velocity = Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity); + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length))); + } else { + invokeCallback('onEnd', () => onEnd!(ScaleEndDetails(velocity: Velocity.zero, pointerCount: _pointerQueue.length))); + } + } + _state = _ScaleState.accepted; + return false; + } + return true; + } + + void _advanceStateMachine(bool shouldStartIfAccepted, PointerDeviceKind pointerDeviceKind) { + if (_state == _ScaleState.ready) _state = _ScaleState.possible; + + // TLAD insert start + if (_pointerQueue.length == 2) { + resolve(GestureDisposition.accepted); + } + // TLAD insert end + + if (_state == _ScaleState.possible) { + final double spanDelta = (_currentSpan - _initialSpan).abs(); + final double focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; + if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind)) resolve(GestureDisposition.accepted); + } else if (_state.index >= _ScaleState.accepted.index) { + resolve(GestureDisposition.accepted); + } + + if (_state == _ScaleState.accepted && shouldStartIfAccepted) { + _state = _ScaleState.started; + _dispatchOnStartCallbackIfNeeded(); + } + + if (_state == _ScaleState.started && onUpdate != null) + invokeCallback('onUpdate', () { + onUpdate!(ScaleUpdateDetails( + scale: _scaleFactor, + horizontalScale: _horizontalScaleFactor, + verticalScale: _verticalScaleFactor, + focalPoint: _currentFocalPoint, + localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), + rotation: _computeRotationFactor(), + pointerCount: _pointerQueue.length, + delta: _currentFocalPoint - _initialFocalPoint, + )); + }); + } + + void _dispatchOnStartCallbackIfNeeded() { + assert(_state == _ScaleState.started); + if (onStart != null) + invokeCallback('onStart', () { + onStart!(ScaleStartDetails( + focalPoint: _currentFocalPoint, + localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint), + pointerCount: _pointerQueue.length, + )); + }); + } + + @override + void acceptGesture(int pointer) { + if (_state == _ScaleState.possible) { + _state = _ScaleState.started; + _dispatchOnStartCallbackIfNeeded(); + if (dragStartBehavior == DragStartBehavior.start) { + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + _initialLine = _currentLine; + _initialHorizontalSpan = _currentHorizontalSpan; + _initialVerticalSpan = _currentVerticalSpan; + } + } + } + + @override + void rejectGesture(int pointer) { + stopTrackingPointer(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_state) { + case _ScaleState.possible: + resolve(GestureDisposition.rejected); + break; + case _ScaleState.ready: + assert(false); // We should have not seen a pointer yet + break; + case _ScaleState.accepted: + break; + case _ScaleState.started: + assert(false); // We should be in the accepted state when user is done + break; + } + _state = _ScaleState.ready; + } + + @override + void dispose() { + _velocityTrackers.clear(); + super.dispose(); + } + + @override + String get debugDescription => 'scale'; +} diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/common/expandable_filter_row.dart similarity index 94% rename from lib/widgets/search/expandable_filter_row.dart rename to lib/widgets/common/expandable_filter_row.dart index 10995787a..0356a6461 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/common/expandable_filter_row.dart @@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget { final String? title; final Iterable filters; final ValueNotifier expandedNotifier; + final bool showGenericIcon; final HeroType Function(CollectionFilter filter)? heroTypeBuilder; final FilterCallback onTap; + final OffsetFilterCallback? onLongPress; const ExpandableFilterRow({ Key? key, this.title, required this.filters, required this.expandedNotifier, + this.showGenericIcon = true, this.heroTypeBuilder, required this.onTap, + required this.onLongPress, }) : super(key: key); static const double horizontalPadding = 8; @@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget { // key `album-{path}` is expected by test driver key: Key(filter.key), filter: filter, + showGenericIcon: showGenericIcon, heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, onTap: onTap, + onLongPress: onLongPress, ); } } diff --git a/lib/widgets/common/fx/transition_image.dart b/lib/widgets/common/fx/transition_image.dart index c530607db..e611fa921 100644 --- a/lib/widgets/common/fx/transition_image.dart +++ b/lib/widgets/common/fx/transition_image.dart @@ -3,7 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc. +// adapted from Flutter `RawImage`, `paintImage()` from `DecorationImagePainter`, etc. // to transition between 2 different fits during hero animation: // - BoxFit.cover at t=0 // - BoxFit.contain at t=1 @@ -190,7 +190,8 @@ class _TransitionImagePainter extends CustomPainter { Offset.zero & inputSize, ); if (background != null) { - canvas.drawRect(destinationRect, Paint()..color = background!); + // deflate to avoid background artifact around opaque image + canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!); } canvas.drawImageRect(image!, sourceRect, destinationRect, paint); } diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index e85987128..336d0bbd6 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -12,7 +12,7 @@ import 'package:provider/provider.dart'; // With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen // because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0. // cf https://github.com/flutter/flutter/issues/49027 -// adapted from `RenderSliverFixedExtentBoxAdaptor` +// adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart` class SectionedListSliver extends StatelessWidget { const SectionedListSliver({Key? key}) : super(key: key); diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index b774dc4c6..8e2f0fabe 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -18,7 +18,7 @@ import 'package:provider/provider.dart'; typedef FilterCallback = void Function(CollectionFilter filter); typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition); -enum HeroType { always, onTap } +enum HeroType { always, onTap, never } @immutable class AvesFilterDecoration { @@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget { final bool removable, showGenericIcon, useFilterColor; final AvesFilterDecoration? decoration; final String? banner; - final Widget? details; + final Widget? leadingOverride, details; final double padding, maxWidth; final HeroType heroType; final FilterCallback? onTap; @@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget { this.useFilterColor = true, this.decoration, this.banner, + this.leadingOverride, this.details, this.padding = 6.0, this.maxWidth = defaultMaxChipWidth, @@ -162,7 +163,7 @@ class _AvesFilterChipState extends State { final chipBackground = Theme.of(context).scaffoldBackgroundColor; final textScaleFactor = MediaQuery.textScaleFactorOf(context); final iconSize = AvesFilterChip.iconSize * textScaleFactor; - final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); + final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null; final decoration = widget.decoration; diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart index 5eb76aea2..0e9d41e15 100644 --- a/lib/widgets/common/magnifier/controller/controller.dart +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -18,13 +18,14 @@ class MagnifierController { late ScaleStateChange _currentScaleState, previousScaleState; MagnifierController({ - Offset initialPosition = Offset.zero, + MagnifierState? initialState, }) : super() { - initial = MagnifierState( - position: initialPosition, - scale: null, - source: ChangeSource.internal, - ); + initial = initialState ?? + const MagnifierState( + position: Offset.zero, + scale: null, + source: ChangeSource.internal, + ); previousState = initial; _currentState = initial; _setState(initial); diff --git a/lib/widgets/common/magnifier/controller/controller_delegate.dart b/lib/widgets/common/magnifier/controller/controller_delegate.dart index a6a79b3d7..13b94d875 100644 --- a/lib/widgets/common/magnifier/controller/controller_delegate.dart +++ b/lib/widgets/common/magnifier/controller/controller_delegate.dart @@ -31,9 +31,16 @@ mixin MagnifierControllerDelegate on State { final List _subscriptions = []; - void startListeners() { - _subscriptions.add(controller.stateStream.listen(_onMagnifierStateChange)); - _subscriptions.add(controller.scaleStateChangeStream.listen(_onScaleStateChange)); + void registerDelegate(MagnifierCore widget) { + _subscriptions.add(widget.controller.stateStream.listen(_onMagnifierStateChange)); + _subscriptions.add(widget.controller.scaleStateChangeStream.listen(_onScaleStateChange)); + } + + void unregisterDelegate(MagnifierCore oldWidget) { + _animateScale = null; + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); } void _onScaleStateChange(ScaleStateChange scaleStateChange) { @@ -181,14 +188,6 @@ mixin MagnifierControllerDelegate on State { return Offset(finalX, finalY); } - - @override - void dispose() { - _animateScale = null; - _subscriptions.forEach((sub) => sub.cancel()); - _subscriptions.clear(); - super.dispose(); - } } /// Simple class to store a min and a max value diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index a84051e1c..214cb5089 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -12,30 +12,25 @@ import 'package:flutter/widgets.dart'; /// Internal widget in which controls all animations lifecycle, core responses /// to user gestures, updates to the controller state and mounts the entire Layout class MagnifierCore extends StatefulWidget { + final MagnifierController controller; + final ScaleStateCycle scaleStateCycle; + final bool applyScale; + final double panInertia; + final MagnifierTapCallback? onTap; + final Widget child; + const MagnifierCore({ Key? key, - required this.child, - required this.onTap, required this.controller, required this.scaleStateCycle, required this.applyScale, this.panInertia = .2, + required this.onTap, + required this.child, }) : super(key: key); - final Widget child; - - final MagnifierController controller; - final ScaleStateCycle scaleStateCycle; - - final MagnifierTapCallback? onTap; - - final bool applyScale; - final double panInertia; - @override - State createState() { - return _MagnifierCoreState(); - } + State createState() => _MagnifierCoreState(); } class _MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { @@ -52,6 +47,45 @@ class _MagnifierCoreState extends State with TickerProviderStateM ScaleBoundaries? cachedScaleBoundaries; + @override + void initState() { + super.initState(); + _scaleAnimationController = AnimationController(vsync: this) + ..addListener(handleScaleAnimation) + ..addStatusListener(onAnimationStatus); + _positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant MagnifierCore oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != widget.controller) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + _scaleAnimationController.dispose(); + _positionAnimationController.dispose(); + super.dispose(); + } + + void _registerWidget(MagnifierCore widget) { + registerDelegate(widget); + cachedScaleBoundaries = widget.controller.scaleBoundaries; + setScaleStateUpdateAnimation(animateOnScaleStateUpdate); + } + + void _unregisterWidget(MagnifierCore oldWidget) { + unregisterDelegate(oldWidget); + cachedScaleBoundaries = null; + } + void handleScaleAnimation() { setScale(_scaleAnimation.value, ChangeSource.animation); } @@ -202,33 +236,11 @@ class _MagnifierCoreState extends State with TickerProviderStateM } } - @override - void initState() { - super.initState(); - _scaleAnimationController = AnimationController(vsync: this)..addListener(handleScaleAnimation); - _scaleAnimationController.addStatusListener(onAnimationStatus); - - _positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate); - - startListeners(); - setScaleStateUpdateAnimation(animateOnScaleStateUpdate); - - cachedScaleBoundaries = widget.controller.scaleBoundaries; - } - void animateOnScaleStateUpdate(double? prevScale, double? nextScale, Offset nextPosition) { animateScale(prevScale, nextScale); animatePosition(controller.position, nextPosition); } - @override - void dispose() { - _scaleAnimationController.removeStatusListener(onAnimationStatus); - _scaleAnimationController.dispose(); - _positionAnimationController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { // Check if we need a recalc on the scale diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 17091e4d5..36ab155cd 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -7,7 +7,7 @@ import 'package:aves/widgets/common/magnifier/scale/state.dart'; import 'package:flutter/material.dart'; /* - `Magnifier` is derived from `photo_view` package v0.9.2: + adapted from package `photo_view` v0.9.2: - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) - removed rotation and many customization parameters - removed ignorable/ignoring partial notifiers @@ -66,8 +66,8 @@ class Magnifier extends StatelessWidget { return MagnifierCore( controller: controller, scaleStateCycle: scaleStateCycle, - onTap: onTap, applyScale: applyScale, + onTap: onTap, child: child, ); }, diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index 0745868a2..dc5156dcd 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -135,8 +135,8 @@ class MapButtonPanel extends StatelessWidget { child: MapOverlayButton( icon: const Icon(AIcons.layers), onPressed: () async { - final hasPlayServices = await availability.hasPlayServices; - final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices); + final canUseGoogleMaps = await availability.canUseGoogleMaps; + final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || canUseGoogleMaps); final preferredStyle = settings.infoMapStyle; final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first; final style = await showDialog( diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 3fe2d3905..b797dd5cf 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -268,7 +268,7 @@ class _GeoMapState extends State { void _onCollectionChanged() { _defaultMarkerCluster = _buildFluster(); _slowMarkerCluster = null; - _clusterChangeNotifier.notifyListeners(); + _clusterChangeNotifier.notify(); } Fluster _buildFluster({int nodeSize = 64}) { diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index e4f6d4485..c341be9bc 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -134,7 +134,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), onRendered: (key, bitmap) { _markerBitmaps[key] = bitmap; - _markerBitmapChangeNotifier.notifyListeners(); + _markerBitmapChangeNotifier.notify(); }, ), MapDecorator( diff --git a/lib/widgets/common/map/leaflet/tile_layers.dart b/lib/widgets/common/map/leaflet/tile_layers.dart index 4a2649d0c..759502bcb 100644 --- a/lib/widgets/common/map/leaflet/tile_layers.dart +++ b/lib/widgets/common/map/leaflet/tile_layers.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:provider/provider.dart'; @@ -11,6 +12,7 @@ class OSMHotLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', subdomains: ['a', 'b', 'c'], + tileProvider: _NetworkTileProvider(), retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); @@ -26,6 +28,7 @@ class StamenTonerLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', subdomains: ['a', 'b', 'c', 'd'], + tileProvider: _NetworkTileProvider(), retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); @@ -41,8 +44,22 @@ class StamenWatercolorLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', subdomains: ['a', 'b', 'c', 'd'], + tileProvider: _NetworkTileProvider(), retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } } + +class _NetworkTileProvider extends NetworkTileProvider { + final Map headers = { + 'User-Agent': device.userAgent, + }; + + _NetworkTileProvider(); + + @override + ImageProvider getImage(Coords coords, TileLayerOptions options) { + return NetworkImage(getTileUrl(coords, options), headers: headers); + } +} diff --git a/lib/widgets/common/providers/query_provider.dart b/lib/widgets/common/providers/query_provider.dart index 75a062eec..472d444e8 100644 --- a/lib/widgets/common/providers/query_provider.dart +++ b/lib/widgets/common/providers/query_provider.dart @@ -3,17 +3,19 @@ import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; class QueryProvider extends StatelessWidget { + final String? initialQuery; final Widget child; const QueryProvider({ Key? key, + required this.initialQuery, required this.child, }) : super(key: key); @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (context) => Query(), + create: (context) => Query(initialValue: initialQuery), child: child, ); } diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index ea1e624ca..054cb20e4 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,10 +2,12 @@ import 'dart:ui' as ui; import 'package:aves/model/highlight.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; @@ -49,95 +51,116 @@ class _GridScaleGestureDetectorState extends State(); + // as of Flutter v2.5.3, `ScaleGestureRecognizer` does not work well + // when combined with the `VerticalDragGestureRecognizer` inside a `GridView`, + // so it is modified to eagerly accept the gesture + // when multiple pointers are involved, and take priority over drag gestures. + return RawGestureDetector( + gestures: { + EagerScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => EagerScaleGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onScaleStart + ..onUpdate = _onScaleUpdate + ..onEnd = _onScaleEnd + ..dragStartBehavior = DragStartBehavior.start; + }, + ), + }, + child: child, + ); + } - final scrollableContext = widget.scrollableKey.currentContext!; - final scrollableBox = scrollableContext.findRenderObject() as RenderBox; - final renderMetaData = _getClosestRenderMetadata( - box: scrollableBox, - localFocalPoint: details.localFocalPoint, - spacing: tileExtentController.spacing, - ); - // abort if we cannot find an image to show on overlay - if (renderMetaData == null) return; - _metadata = renderMetaData.metaData; - _startSize = renderMetaData.size; - _scaledSizeNotifier = ValueNotifier(_startSize!); + void _onScaleStart(ScaleStartDetails details) { + // the gesture detector wrongly detects a new scaling gesture + // when scaling ends and we apply the new extent, so we prevent this + // until we scaled and scrolled to the tile in the new grid + if (_applyingScale) return; - // not the same as `MediaQuery.size.width`, because of screen insets/padding - final gridWidth = scrollableBox.size.width; + final tileExtentController = context.read(); - _extentMin = tileExtentController.effectiveExtentMin; - _extentMax = tileExtentController.effectiveExtentMax; + final scrollableContext = widget.scrollableKey.currentContext!; + final scrollableBox = scrollableContext.findRenderObject() as RenderBox; + final renderMetaData = _getClosestRenderMetadata( + box: scrollableBox, + localFocalPoint: details.localFocalPoint, + spacing: tileExtentController.spacing, + ); + // abort if we cannot find an image to show on overlay + if (renderMetaData == null) return; + _metadata = renderMetaData.metaData; + _startSize = renderMetaData.size; + _scaledSizeNotifier = ValueNotifier(_startSize!); - final halfSize = _startSize! / 2; - final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height)); - _overlayEntry = OverlayEntry( - builder: (context) => ScaleOverlay( - builder: (scaledTileSize) => SizedBox.fromSize( - size: scaledTileSize, - child: GridTheme( - extent: scaledTileSize.width, - child: widget.scaledBuilder(_metadata!.item, scaledTileSize), - ), - ), - center: thumbnailCenter, - viewportWidth: gridWidth, - gridBuilder: widget.gridBuilder, - scaledSizeNotifier: _scaledSizeNotifier!, + // not the same as `MediaQuery.size.width`, because of screen insets/padding + final gridWidth = scrollableBox.size.width; + + _extentMin = tileExtentController.effectiveExtentMin; + _extentMax = tileExtentController.effectiveExtentMax; + + final halfSize = _startSize! / 2; + final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height)); + _overlayEntry = OverlayEntry( + builder: (context) => ScaleOverlay( + builder: (scaledTileSize) => SizedBox.fromSize( + size: scaledTileSize, + child: GridTheme( + extent: scaledTileSize.width, + child: widget.scaledBuilder(_metadata!.item, scaledTileSize), ), - ); - Overlay.of(scrollableContext)!.insert(_overlayEntry!); - }, - onScaleUpdate: (details) { - if (_scaledSizeNotifier == null) return; - final s = details.scale; - final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!); - _scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth)); - }, - onScaleEnd: (details) { - if (_scaledSizeNotifier == null) return; - if (_overlayEntry != null) { - _overlayEntry!.remove(); - _overlayEntry = null; - } - - _applyingScale = true; - final tileExtentController = context.read(); - final oldExtent = tileExtentController.extentNotifier.value; - // sanitize and update grid layout if necessary - final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width); - _scaledSizeNotifier = null; - if (newExtent == oldExtent) { - _applyingScale = false; - } else { - // scroll to show the focal point thumbnail at its new position - WidgetsBinding.instance!.addPostFrameCallback((_) { - final trackItem = _metadata!.item; - final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; - context.read().trackItem(trackItem, animate: false, highlightItem: highlightItem); - _applyingScale = false; - }); - } - }, - child: GestureDetector( - // Horizontal/vertical drag gestures are interpreted as scaling - // if they are not handled by `onHorizontalDragStart`/`onVerticalDragStart` - // at the scaling `GestureDetector` level, or handled beforehand down the widget tree. - // Setting `onHorizontalDragStart`, `onVerticalDragStart`, and `onScaleStart` - // all at once is not allowed, so we use another `GestureDetector` for that. - onVerticalDragStart: (details) {}, - onHorizontalDragStart: (details) {}, - child: widget.child, + ), + center: thumbnailCenter, + viewportWidth: gridWidth, + gridBuilder: widget.gridBuilder, + scaledSizeNotifier: _scaledSizeNotifier!, ), ); + Overlay.of(scrollableContext)!.insert(_overlayEntry!); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + if (_scaledSizeNotifier == null) return; + final s = details.scale; + final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!); + _scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth)); + } + + void _onScaleEnd(ScaleEndDetails details) { + if (_scaledSizeNotifier == null) return; + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + + _applyingScale = true; + final tileExtentController = context.read(); + final oldExtent = tileExtentController.extentNotifier.value; + // sanitize and update grid layout if necessary + final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width); + _scaledSizeNotifier = null; + if (newExtent == oldExtent) { + _applyingScale = false; + } else { + // scroll to show the focal point thumbnail at its new position + WidgetsBinding.instance!.addPostFrameCallback((_) { + final trackItem = _metadata!.item; + final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; + context.read().trackItem(trackItem, animate: false, highlightItem: highlightItem); + _applyingScale = false; + }); + } } RenderMetaData? _getClosestRenderMetadata({ diff --git a/lib/widgets/debug/cache.dart b/lib/widgets/debug/cache.dart index 90a08dd43..9dca26878 100644 --- a/lib/widgets/debug/cache.dart +++ b/lib/widgets/debug/cache.dart @@ -25,7 +25,7 @@ class _DebugCacheSectionState extends State with AutomaticKee Row( children: [ Expanded( - child: Text('Image cache:\n\t${imageCache!.currentSize}/${imageCache!.maximumSize} items\n\t${formatFilesize(imageCache!.currentSizeBytes)}/${formatFilesize(imageCache!.maximumSizeBytes)}'), + child: Text('Image cache:\n\t${imageCache!.currentSize}/${imageCache!.maximumSize} items\n\t${formatFileSize('en_US', imageCache!.currentSizeBytes)}/${formatFileSize('en_US', imageCache!.maximumSizeBytes)}'), ), const SizedBox(width: 8), ElevatedButton( diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 12c0c973d..d734ac5d5 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -53,7 +53,7 @@ class _DebugAppDatabaseSectionState extends State with return Row( children: [ Expanded( - child: Text('DB file size: ${formatFilesize(snapshot.data!)}'), + child: Text('DB file size: ${formatFileSize('en_US', snapshot.data!)}'), ), const SizedBox(width: 8), ElevatedButton( diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index da2f29f55..2dccf852d 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -46,7 +46,7 @@ class _DebugStorageSectionState extends State with Automati 'isPrimary': '${v.isPrimary}', 'isRemovable': '${v.isRemovable}', 'state': v.state, - if (freeSpace != null) 'freeSpace': formatFilesize(freeSpace), + if (freeSpace != null) 'freeSpace': formatFileSize('en_US', freeSpace), }, ), ), diff --git a/lib/widgets/dialogs/edit_entry_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart similarity index 99% rename from lib/widgets/dialogs/edit_entry_date_dialog.dart rename to lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart index 0d6056f79..970f0ac89 100644 --- a/lib/widgets/dialogs/edit_entry_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart @@ -9,7 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'aves_dialog.dart'; +import '../aves_dialog.dart'; class EditEntryDateDialog extends StatefulWidget { final AvesEntry entry; diff --git a/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart new file mode 100644 index 000000000..cebab3027 --- /dev/null +++ b/lib/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart @@ -0,0 +1,278 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/expandable_filter_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TagEditorPage extends StatefulWidget { + static const routeName = '/info/tag_editor'; + + final Map> tagsByEntry; + + const TagEditorPage({ + Key? key, + required this.tagsByEntry, + }) : super(key: key); + + @override + _TagEditorPageState createState() => _TagEditorPageState(); +} + +class _TagEditorPageState extends State { + final TextEditingController _newTagTextController = TextEditingController(); + final FocusNode _newTagTextFocusNode = FocusNode(); + final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); + late final List _topTags; + + static final List _recentTags = []; + + static const Color untaggedColor = Colors.blueGrey; + static const int tagHistoryCount = 10; + + Map> get tagsByEntry => widget.tagsByEntry; + + @override + void initState() { + super.initState(); + _initTopTags(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final showCount = tagsByEntry.length > 1; + final Map entryCountByTag = {}; + tagsByEntry.entries.forEach((kv) { + kv.value.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> sortedTags = _sortEntryCountByTag(entryCountByTag); + + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(l10n.tagEditorPageTitle), + actions: [ + IconButton( + icon: const Icon(AIcons.reset), + onPressed: _reset, + tooltip: l10n.resetButtonTooltip, + ), + ], + ), + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: _expandedSectionNotifier, + builder: (context, expandedSection, child) { + return ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + final upQuery = value.text.trim().toUpperCase(); + bool containQuery(String s) => s.toUpperCase().contains(upQuery); + final recentFilters = _recentTags.where(containQuery).map((v) => TagFilter(v)).toList(); + final topTagFilters = _topTags.where(containQuery).map((v) => TagFilter(v)).toList(); + return ListView( + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _newTagTextController, + focusNode: _newTagTextFocusNode, + decoration: InputDecoration( + labelText: l10n.tagEditorPageNewTagFieldLabel, + ), + autofocus: true, + onSubmitted: (newTag) { + _addTag(newTag); + _newTagTextFocusNode.requestFocus(); + }, + ), + ), + ValueListenableBuilder( + valueListenable: _newTagTextController, + builder: (context, value, child) { + return IconButton( + icon: const Icon(AIcons.add), + onPressed: value.text.isEmpty ? null : () => _addTag(_newTagTextController.text), + tooltip: l10n.tagEditorPageAddTagTooltip, + ); + }, + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: AnimatedCrossFade( + firstChild: ConstrainedBox( + constraints: const BoxConstraints(minHeight: AvesFilterChip.minChipHeight), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(AIcons.tagOff, color: untaggedColor), + const SizedBox(width: 8), + Text( + l10n.filterTagEmptyLabel, + style: const TextStyle(color: untaggedColor), + ), + ], + ), + ), + ), + secondChild: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: sortedTags.map((kv) { + final tag = kv.key; + return AvesFilterChip( + filter: TagFilter(tag), + removable: true, + showGenericIcon: false, + leadingOverride: showCount ? _TagCount(count: kv.value) : null, + onTap: (filter) => _removeTag(tag), + onLongPress: null, + ); + }).toList(), + ), + ), + crossFadeState: sortedTags.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: Durations.tagEditorTransition, + ), + ), + const Divider(height: 0), + _FilterRow( + title: l10n.tagEditorSectionRecent, + filters: recentFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + _FilterRow( + title: l10n.statsTopTags, + filters: topTagFilters, + expandedNotifier: _expandedSectionNotifier, + onTap: _addTag, + ), + ], + ); + }, + ); + }, + ), + ), + ), + ); + } + + void _initTopTags() { + final Map entryCountByTag = {}; + final visibleEntries = context.read()?.visibleEntries; + visibleEntries?.forEach((entry) { + entry.tags.forEach((tag) => entryCountByTag[tag] = (entryCountByTag[tag] ?? 0) + 1); + }); + List> sortedTopTags = _sortEntryCountByTag(entryCountByTag); + _topTags = sortedTopTags.map((kv) => kv.key).toList(); + } + + List> _sortEntryCountByTag(Map entryCountByTag) { + return entryCountByTag.entries.toList() + ..sort((kv1, kv2) { + final c = kv2.value.compareTo(kv1.value); + return c != 0 ? c : compareAsciiUpperCase(kv1.key, kv2.key); + }); + } + + void _reset() { + setState(() => tagsByEntry.forEach((entry, tags) { + tags + ..clear() + ..addAll(entry.tags); + })); + } + + void _addTag(String newTag) { + if (newTag.isNotEmpty) { + setState(() { + _recentTags + ..remove(newTag) + ..insert(0, newTag) + ..removeRange(min(tagHistoryCount, _recentTags.length), _recentTags.length); + tagsByEntry.forEach((entry, tags) => tags.add(newTag)); + }); + _newTagTextController.clear(); + } + } + + void _removeTag(String tag) { + setState(() => tagsByEntry.forEach((entry, tags) => tags.remove(tag))); + } +} + +class _FilterRow extends StatelessWidget { + final String title; + final List filters; + final ValueNotifier expandedNotifier; + final void Function(String tag) onTap; + + const _FilterRow({ + Key? key, + required this.title, + required this.filters, + required this.expandedNotifier, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return filters.isEmpty + ? const SizedBox() + : ExpandableFilterRow( + title: title, + filters: filters, + expandedNotifier: expandedNotifier, + showGenericIcon: false, + onTap: (filter) => onTap((filter as TagFilter).tag), + onLongPress: null, + ); + } +} + +class _TagCount extends StatelessWidget { + final int count; + + const _TagCount({ + Key? key, + required this.count, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 6), + decoration: BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: DefaultTextStyle.of(context).style.color!, + )), + borderRadius: const BorderRadius.all(Radius.circular(123)), + ), + child: Text( + '$count', + style: const TextStyle(fontSize: AvesFilterChip.fontSize), + ), + ); + } +} diff --git a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart similarity index 99% rename from lib/widgets/dialogs/remove_entry_metadata_dialog.dart rename to lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart index 2ecc94bef..54c16d7ec 100644 --- a/lib/widgets/dialogs/remove_entry_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart @@ -9,7 +9,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'aves_dialog.dart'; +import '../aves_dialog.dart'; class RemoveEntryMetadataDialog extends StatefulWidget { final bool showJpegTypes; diff --git a/lib/widgets/dialogs/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart similarity index 98% rename from lib/widgets/dialogs/rename_entry_dialog.dart rename to lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index 26ba06ca4..7458f5043 100644 --- a/lib/widgets/dialogs/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -5,7 +5,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -import 'aves_dialog.dart'; +import '../aves_dialog.dart'; class RenameEntryDialog extends StatefulWidget { final AvesEntry entry; diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart similarity index 97% rename from lib/widgets/dialogs/cover_selection_dialog.dart rename to lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index 2dca81942..6b9ee9663 100644 --- a/lib/widgets/dialogs/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -4,6 +4,7 @@ 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/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; @@ -96,6 +97,7 @@ class _CoverSelectionDialogState extends State { extent: extent, coverEntry: _isCustom ? _customEntry : _recentEntry, onTap: (filter) => _pickEntry(), + heroType: HeroType.never, ), ), ], diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart similarity index 99% rename from lib/widgets/dialogs/create_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/create_album_dialog.dart index 2e5965d24..738642c50 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -8,7 +8,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'aves_dialog.dart'; +import '../aves_dialog.dart'; class CreateAlbumDialog extends StatefulWidget { const CreateAlbumDialog({Key? key}) : super(key: key); diff --git a/lib/widgets/dialogs/rename_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart similarity index 100% rename from lib/widgets/dialogs/rename_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/rename_album_dialog.dart diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart index 51b2f497d..d12c7a1df 100644 --- a/lib/widgets/dialogs/item_pick_dialog.dart +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -1,5 +1,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/query.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -9,6 +10,7 @@ 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'; +import 'package:collection/collection.dart'; class ItemPickDialog extends StatefulWidget { static const routeName = '/item_pick'; @@ -35,12 +37,14 @@ class _ItemPickDialogState extends State { @override Widget build(BuildContext context) { + final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return ListenableProvider>.value( value: ValueNotifier(AppMode.pickInternal), child: MediaQueryDataProvider( child: Scaffold( body: SelectionProvider( child: QueryProvider( + initialQuery: liveFilter?.query, child: GestureAreaProtectorStack( child: SafeArea( bottom: false, diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index 54daa7fea..715ddd76b 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -46,6 +46,7 @@ class DrawerFilterTitle extends StatelessWidget { if (filter == FavouriteFilter.instance) return l10n.drawerCollectionFavourites; if (filter == MimeFilter.image) return l10n.drawerCollectionImages; if (filter == MimeFilter.video) return l10n.drawerCollectionVideos; + if (filter == TypeFilter.animated) return l10n.drawerCollectionAnimated; if (filter == TypeFilter.motionPhoto) return l10n.drawerCollectionMotionPhotos; if (filter == TypeFilter.panorama) return l10n.drawerCollectionPanoramas; if (filter == TypeFilter.raw) return l10n.drawerCollectionRaws; diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 1a4634d94..af79ac67d 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -12,9 +12,10 @@ import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; -import 'package:aves/widgets/dialogs/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/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'; @@ -79,6 +80,7 @@ class _AlbumPickPageState extends State { text: context.l10n.albumEmpty, ), onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter).album), + heroType: HeroType.never, ), ); }, 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 606c928a8..0ee31819f 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -18,8 +18,8 @@ import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; -import 'package:aves/widgets/dialogs/create_album_dialog.dart'; -import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; 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 bb02c7bf1..4a2167d31 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -15,7 +15,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; 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/dialogs/filter_editors/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'; diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index dc1375a7a..2a8ceffdd 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -12,7 +12,6 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; @@ -28,6 +27,7 @@ class CoveredFilterChip extends StatelessWidget { final bool pinned; final String? banner; final FilterCallback? onTap; + final HeroType heroType; const CoveredFilterChip({ Key? key, @@ -38,6 +38,7 @@ class CoveredFilterChip extends StatelessWidget { this.pinned = false, this.banner, this.onTap, + this.heroType = HeroType.onTap, }) : thumbnailExtent = thumbnailExtent ?? extent, super(key: key); @@ -116,17 +117,22 @@ class CoveredFilterChip extends StatelessWidget { ); }, child: entry == null - ? Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Colors.white, - stringToColor(filter.getLabel(context)), - ], - ), - ), + ? FutureBuilder( + future: filter.color(context), + builder: (context, snapshot) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white, + snapshot.data ?? Colors.white, + ], + ), + ), + ); + }, ) : ThumbnailImage( entry: entry, @@ -138,6 +144,7 @@ class CoveredFilterChip extends StatelessWidget { banner: banner, details: _buildDetails(source, filter), padding: titlePadding, + heroType: heroType, onTap: onTap, onLongPress: null, ); diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 135d9e8ba..4b609534e 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -51,6 +51,7 @@ class FilterGridPage extends StatelessWidget { final QueryTest? applyQuery; final Widget Function() emptyBuilder; final FilterCallback onTap; + final HeroType heroType; const FilterGridPage({ Key? key, @@ -66,6 +67,7 @@ class FilterGridPage extends StatelessWidget { this.applyQuery, required this.emptyBuilder, required this.onTap, + required this.heroType, }) : super(key: key); static const Color detailColor = Color(0xFFE0E0E0); @@ -104,6 +106,7 @@ class FilterGridPage extends StatelessWidget { applyQuery: applyQuery, emptyBuilder: emptyBuilder, onTap: onTap, + heroType: heroType, ), ), ), @@ -129,6 +132,7 @@ class FilterGrid extends StatefulWidget { final QueryTest? applyQuery; final Widget Function() emptyBuilder; final FilterCallback onTap; + final HeroType heroType; const FilterGrid({ Key? key, @@ -144,6 +148,7 @@ class FilterGrid extends StatefulWidget { required this.applyQuery, required this.emptyBuilder, required this.onTap, + required this.heroType, }) : super(key: key); @override @@ -181,6 +186,7 @@ class _FilterGridState extends State> applyQuery: widget.applyQuery, emptyBuilder: widget.emptyBuilder, onTap: widget.onTap, + heroType: widget.heroType, ), ); } @@ -196,6 +202,7 @@ class _FilterGridContent extends StatelessWidget { final Widget Function() emptyBuilder; final QueryTest? applyQuery; final FilterCallback onTap; + final HeroType heroType; final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); @@ -212,6 +219,7 @@ class _FilterGridContent extends StatelessWidget { required this.applyQuery, required this.emptyBuilder, required this.onTap, + required this.heroType, }) : super(key: key) { _appBarHeightNotifier.value = appBarHeight; } @@ -275,6 +283,7 @@ class _FilterGridContent extends StatelessWidget { pinned: pinnedFilters.contains(filter), banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null, onTap: onTap, + heroType: heroType, ), ), ); @@ -432,6 +441,7 @@ class _FilterScaler extends StatelessWidget { extent: tileSize.width, thumbnailExtent: context.read().effectiveExtentMax, pinned: pinnedFilters.contains(filter), + heroType: HeroType.never, ); }, highlightItem: (item) => item.filter, diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 7ce66ed98..7a0b97e02 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -3,6 +3,7 @@ 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/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/common/app_bar.dart'; @@ -57,6 +58,7 @@ class FilterNavigationPage extends StatelessWidget { }, ), onTap: (filter) => _goToCollection(context, filter), + heroType: HeroType.onTap, ), ), ); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 4569ce373..f04182b61 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -79,7 +79,7 @@ class _HomePageState extends State { final intentData = widget.intentData ?? await ViewerService.getIntentData(); if (intentData.isNotEmpty) { final action = intentData['action']; - await reportService.log('Intent action=$action'); + await reportService.log('Intent data=$intentData'); switch (action) { case 'view': _viewerEntry = await _initViewerEntry( @@ -133,10 +133,14 @@ class _HomePageState extends State { } Future _initViewerEntry({required String uri, required String? mimeType}) async { + if (uri.startsWith('/')) { + // convert this file path to a proper URI + uri = Uri.file(uri).toString(); + } final entry = await mediaFileService.getEntry(uri, mimeType); if (entry != null) { // cataloguing is essential for coordinates and video rotation - await entry.catalog(background: false, persist: false, force: false); + await entry.catalog(background: false, force: false, persist: false); } return entry; } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 938b7ddde..24927b16c 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -15,9 +15,9 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/search/expandable_filter_row.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -27,10 +27,10 @@ import 'package:provider/provider.dart'; class CollectionSearchDelegate { final CollectionSource source; final CollectionLens? parentCollection; - final ValueNotifier expandedSectionNotifier = ValueNotifier(null); + final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); final bool canPop; - static const searchHistoryCount = 10; + static const int searchHistoryCount = 10; static final typeFilters = [ FavouriteFilter.instance, MimeFilter.image, @@ -90,7 +90,7 @@ class CollectionSearchDelegate { bool containQuery(String s) => s.toUpperCase().contains(upQuery); return SafeArea( child: ValueListenableBuilder( - valueListenable: expandedSectionNotifier, + valueListenable: _expandedSectionNotifier, builder: (context, expandedSection, child) { final queryFilter = _buildQueryFilter(false); return Selector>( @@ -195,9 +195,10 @@ class CollectionSearchDelegate { return ExpandableFilterRow( title: title, filters: filters, - expandedNotifier: expandedSectionNotifier, + expandedNotifier: _expandedSectionNotifier, heroTypeBuilder: heroTypeBuilder, onTap: (filter) => _select(context, filter is QueryFilter ? QueryFilter(filter.query) : filter), + onLongPress: AvesFilterChip.showDefaultLongPressMenu, ); } @@ -272,7 +273,7 @@ class CollectionSearchDelegate { focusNode?.unfocus(); } - // adapted from `SearchDelegate` + // adapted from Flutter `SearchDelegate` in `/material/search.dart` void showResults(BuildContext context) { focusNode?.unfocus(); @@ -310,10 +311,10 @@ class CollectionSearchDelegate { SearchPageRoute? route; } -// adapted from `SearchDelegate` +// adapted from Flutter `_SearchBody` in `/material/search.dart` enum SearchBody { suggestions, results } -// adapted from `SearchDelegate` +// adapted from Flutter `_SearchPageRoute` in `/material/search.dart` class SearchPageRoute extends PageRoute { SearchPageRoute({ required this.delegate, diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index 6c329871f..1b1dc7f74 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -145,7 +145,7 @@ class _QuickActionEditorBodyState extends State extends State extends State _reordering = false); return true; } @@ -305,7 +305,7 @@ class _QuickActionEditorBodyState extends State DraggedPlaceholder(child: _buildQuickActionButton(action, animation)), duration: Durations.quickActionListAnimation, ); - _quickActionsChangeNotifier.notifyListeners(); + _quickActionsChangeNotifier.notify(); return true; } diff --git a/lib/widgets/settings/language/locale.dart b/lib/widgets/settings/language/locale.dart index 18526966e..9935a5a81 100644 --- a/lib/widgets/settings/language/locale.dart +++ b/lib/widgets/settings/language/locale.dart @@ -8,6 +8,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; class LocaleTile extends StatelessWidget { static const _systemLocaleOption = Locale('system'); @@ -16,10 +17,14 @@ class LocaleTile extends StatelessWidget { @override Widget build(BuildContext context) { - final current = settings.locale; return ListTile( title: Text(context.l10n.settingsLanguage), - subtitle: Text(current == null ? context.l10n.settingsSystemDefault : _getLocaleName(current)), + subtitle: Selector( + selector: (context, s) => settings.locale, + builder: (context, locale, child) { + return Text(locale == null ? context.l10n.settingsSystemDefault : _getLocaleName(locale)); + }, + ), onTap: () async { final value = await showDialog( context: context, @@ -44,6 +49,8 @@ class LocaleTile extends StatelessWidget { switch (locale.languageCode) { case 'en': return 'English'; + case 'fr': + return 'Français'; case 'ko': return '한국어'; case 'ru': diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart new file mode 100644 index 000000000..7889ce085 --- /dev/null +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -0,0 +1,107 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +class CrumbLine extends StatefulWidget { + final VolumeRelativeDirectory directory; + final void Function(String path) onTap; + + const CrumbLine({ + Key? key, + required this.directory, + required this.onTap, + }) : super(key: key); + + @override + _CrumbLineState createState() => _CrumbLineState(); +} + +class _CrumbLineState extends State { + final ScrollController _controller = ScrollController(); + + VolumeRelativeDirectory get directory => widget.directory; + + @override + void didUpdateWidget(covariant CrumbLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.directory.relativeDir.length > oldWidget.directory.relativeDir.length) { + // scroll to show last crumb + WidgetsBinding.instance!.addPostFrameCallback((_) { + final extent = _controller.position.maxScrollExtent; + _controller.animateTo( + extent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutQuad, + ); + }); + } + } + + @override + Widget build(BuildContext context) { + List parts = [ + directory.getVolumeDescription(context), + ...p.split(directory.relativeDir), + ]; + final crumbStyle = Theme.of(context).textTheme.bodyText2; + final crumbColor = crumbStyle!.color!.withOpacity(.4); + return DefaultTextStyle( + style: crumbStyle.copyWith( + color: crumbColor, + fontWeight: FontWeight.w500, + ), + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: _controller, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + Widget _buildText(String text) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text(text), + ); + + if (index >= parts.length) return const SizedBox(); + final text = parts[index]; + if (index == parts.length - 1) { + return Center( + child: DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + ), + child: _buildText(text), + ), + ); + } + return GestureDetector( + onTap: () { + final path = p.joinAll([ + directory.volumePath, + ...parts.skip(1).take(index), + ]); + widget.onTap(path); + }, + child: Container( + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildText(text), + Icon( + AIcons.next, + color: crumbColor, + ), + ], + ), + ), + ); + }, + itemCount: parts.length, + ), + ); + } +} diff --git a/lib/widgets/settings/privacy/file_picker/file_picker.dart b/lib/widgets/settings/privacy/file_picker/file_picker.dart new file mode 100644 index 000000000..6a0eb82c9 --- /dev/null +++ b/lib/widgets/settings/privacy/file_picker/file_picker.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:path/path.dart' as p; + +class FilePicker extends StatefulWidget { + static const routeName = '/file_picker'; + + const FilePicker({Key? key}) : super(key: key); + + @override + _FilePickerState createState() => _FilePickerState(); +} + +class _FilePickerState extends State { + late VolumeRelativeDirectory _directory; + List? _contents; + + Set get volumes => androidFileUtils.storageVolumes; + + String get currentDirectoryPath => p.join(_directory.volumePath, _directory.relativeDir); + + @override + void initState() { + super.initState(); + final primaryVolume = volumes.firstWhere((v) => v.isPrimary); + _goTo(primaryVolume.path); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final showHidden = settings.filePickerShowHiddenFiles; + final visibleContents = _contents?.where((v) { + if (showHidden) { + return true; + } else { + final isHidden = p.split(v.path).last.startsWith('.'); + return !isHidden; + } + }).toList(); + return WillPopScope( + onWillPop: () { + if (_directory.relativeDir.isEmpty) { + return SynchronousFuture(true); + } + final parent = p.dirname(currentDirectoryPath); + _goTo(parent); + setState(() {}); + return SynchronousFuture(false); + }, + child: Scaffold( + appBar: AppBar( + title: Text(_getTitle(context)), + actions: [ + MenuIconTheme( + child: PopupMenuButton<_PickerAction>( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: _PickerAction.toggleHiddenView, + child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles), + ), + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (action) { + case _PickerAction.toggleHiddenView: + settings.filePickerShowHiddenFiles = !showHidden; + setState(() {}); + break; + } + }, + ), + ), + ], + ), + drawer: _buildDrawer(context), + body: SafeArea( + child: Column( + children: [ + SizedBox( + height: kMinInteractiveDimension, + child: CrumbLine( + directory: _directory, + onTap: (path) { + _goTo(path); + setState(() {}); + }, + ), + ), + const Divider(height: 0), + Expanded( + child: visibleContents == null + ? const SizedBox() + : visibleContents.isEmpty + ? Center( + child: EmptyContent( + icon: AIcons.folder, + text: l10n.filePickerNoItems, + ), + ) + : ListView.builder( + itemCount: visibleContents.length, + itemBuilder: (context, index) { + return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox(); + }, + ), + ), + const Divider(height: 0), + Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.filePickerUseThisFolder, + onPressed: () => Navigator.pop(context, currentDirectoryPath), + ), + ), + ], + ), + ), + ), + ); + } + + String _getTitle(BuildContext context) { + if (_directory.relativeDir.isEmpty) { + return _directory.getVolumeDescription(context); + } + return p.split(_directory.relativeDir).last; + } + + Widget _buildDrawer(BuildContext context) { + return Drawer( + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.filePickerOpenFrom, + style: Theme.of(context).textTheme.headline5, + ), + ), + ...volumes.map((v) { + final icon = v.isRemovable ? AIcons.removableStorage : AIcons.mainStorage; + return ListTile( + leading: Icon(icon), + title: Text(v.getDescription(context)), + onTap: () async { + Navigator.pop(context); + await Future.delayed(Durations.drawerTransitionAnimation); + _goTo(v.path); + setState(() {}); + }, + selected: _directory.volumePath == v.path, + ); + }) + ], + ), + ); + } + + Widget _buildContentLine(BuildContext context, FileSystemEntity content) { + return ListTile( + leading: const Icon(AIcons.folder), + title: Text(p.split(content.path).last), + onTap: () { + _goTo(content.path); + setState(() {}); + }, + ); + } + + void _goTo(String path) { + _directory = VolumeRelativeDirectory.fromPath(path)!; + _contents = null; + final contents = []; + Directory(currentDirectoryPath).list().listen((event) { + final entity = event.absolute; + if (entity is Directory) { + contents.add(entity); + } + }, onDone: () { + _contents = contents..sort((a, b) => compareAsciiUpperCase(p.split(a.path).last, p.split(b.path).last)); + setState(() {}); + }); + } +} + +enum _PickerAction { toggleHiddenView } diff --git a/lib/widgets/settings/privacy/hidden_filters.dart b/lib/widgets/settings/privacy/hidden_filters.dart deleted file mode 100644 index 525b99768..000000000 --- a/lib/widgets/settings/privacy/hidden_filters.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/path.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; -import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class HiddenFilterTile extends StatelessWidget { - const HiddenFilterTile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(context.l10n.settingsHiddenFiltersTile), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: HiddenFilterPage.routeName), - builder: (context) => const HiddenFilterPage(), - ), - ); - }, - ); - } -} - -class HiddenFilterPage extends StatelessWidget { - static const routeName = '/settings/hidden_filters'; - - const HiddenFilterPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsHiddenFiltersTitle), - ), - body: SafeArea( - child: Selector>( - selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(), - builder: (context, hiddenFilters, child) { - if (hiddenFilters.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _Header(), - const Divider(), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: EmptyContent( - icon: AIcons.hide, - text: context.l10n.settingsHiddenFiltersEmpty, - ), - ), - ), - ], - ); - } - - final filterList = hiddenFilters.toList()..sort(); - return ListView( - children: [ - const _Header(), - const Divider(), - Padding( - padding: const EdgeInsets.all(8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: filterList - .map((filter) => AvesFilterChip( - filter: filter, - removable: true, - onTap: (filter) => context.read().changeFilterVisibility({filter}, true), - onLongPress: null, - )) - .toList(), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _Header extends StatelessWidget { - const _Header({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - children: [ - const Icon(AIcons.info), - const SizedBox(width: 16), - Expanded(child: Text(context.l10n.settingsHiddenFiltersBanner)), - ], - ), - ); - } -} diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items.dart new file mode 100644 index 000000000..48e5a6d19 --- /dev/null +++ b/lib/widgets/settings/privacy/hidden_items.dart @@ -0,0 +1,208 @@ +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/path.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class HiddenItemsTile extends StatelessWidget { + const HiddenItemsTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsHiddenItemsTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: HiddenItemsPage.routeName), + builder: (context) => const HiddenItemsPage(), + ), + ); + }, + ); + } +} + +class HiddenItemsPage extends StatelessWidget { + static const routeName = '/settings/hidden_items'; + + const HiddenItemsPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final tabs = >[ + Tuple2( + Tab(text: l10n.settingsHiddenFiltersTitle), + const _HiddenFilters(), + ), + Tuple2( + Tab(text: l10n.settingsHiddenPathsTitle), + const _HiddenPaths(), + ), + ]; + + return MediaQueryDataProvider( + child: DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(l10n.settingsHiddenItemsTitle), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), + ), + ), + body: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), + ), + ), + ), + ), + ); + } +} + +class _HiddenFilters extends StatelessWidget { + const _HiddenFilters({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (context, s) => settings.hiddenFilters.where((v) => v is! PathFilter).toSet(), + builder: (context, hiddenFilters, child) { + if (hiddenFilters.isEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Banner(bannerText: context.l10n.settingsHiddenFiltersBanner), + const Divider(height: 0), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: EmptyContent( + icon: AIcons.hide, + text: context.l10n.settingsHiddenFiltersEmpty, + ), + ), + ), + ], + ); + } + + final filterList = hiddenFilters.toList()..sort(); + return ListView( + children: [ + _Banner(bannerText: context.l10n.settingsHiddenFiltersBanner), + const Divider(height: 0), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: filterList + .map((filter) => AvesFilterChip( + filter: filter, + removable: true, + onTap: (filter) => context.read().changeFilterVisibility({filter}, true), + onLongPress: null, + )) + .toList(), + ), + ), + ], + ); + }, + ); + } +} + +class _HiddenPaths extends StatelessWidget { + const _HiddenPaths({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Selector>( + selector: (context, s) => settings.hiddenFilters.whereType().toSet(), + builder: (context, hiddenPaths, child) { + final pathList = hiddenPaths.toList()..sort(); + return Column( + children: [ + _Banner(bannerText: context.l10n.settingsHiddenPathsBanner), + const Divider(height: 0), + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + ...pathList.map((pathFilter) => ListTile( + title: Text(pathFilter.path), + dense: true, + trailing: IconButton( + icon: const Icon(AIcons.clear), + onPressed: () { + context.read().changeFilterVisibility({pathFilter}, true); + }, + tooltip: context.l10n.removeTooltip, + ), + )), + ], + ), + ), + const Divider(height: 0), + const SizedBox(height: 8), + AvesOutlinedButton( + icon: const Icon(AIcons.add), + label: context.l10n.addPathTooltip, + onPressed: () async { + final path = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: FilePicker.routeName), + builder: (context) => const FilePicker(), + ), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.pageTransitionAnimation * timeDilation); + if (path != null && path.isNotEmpty) { + context.read().changeFilterVisibility({PathFilter(path)}, false); + } + }, + ), + ], + ); + }, + ); + } +} + +class _Banner extends StatelessWidget { + final String bannerText; + + const _Banner({Key? key, required this.bannerText}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(AIcons.info), + const SizedBox(width: 16), + Expanded(child: Text(bannerText)), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/privacy/hidden_paths.dart b/lib/widgets/settings/privacy/hidden_paths.dart deleted file mode 100644 index f3bea046f..000000000 --- a/lib/widgets/settings/privacy/hidden_paths.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:aves/model/filters/path.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class HiddenPathTile extends StatelessWidget { - const HiddenPathTile({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(context.l10n.settingsHiddenPathsTile), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - settings: const RouteSettings(name: HiddenPathPage.routeName), - builder: (context) => const HiddenPathPage(), - ), - ); - }, - ); - } -} - -class HiddenPathPage extends StatelessWidget { - static const routeName = '/settings/hidden_paths'; - - const HiddenPathPage({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(context.l10n.settingsHiddenPathsTitle), - actions: [ - IconButton( - icon: const Icon(AIcons.add), - onPressed: () async { - final path = await storageService.selectDirectory(); - if (path != null && path.isNotEmpty) { - context.read().changeFilterVisibility({PathFilter(path)}, false); - } - }, - tooltip: context.l10n.addPathTooltip, - ), - ], - ), - body: SafeArea( - child: Selector>( - selector: (context, s) => settings.hiddenFilters.whereType().toSet(), - builder: (context, hiddenPaths, child) { - if (hiddenPaths.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _Header(), - const Divider(), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: EmptyContent( - icon: AIcons.hide, - text: context.l10n.settingsHiddenPathsEmpty, - ), - ), - ), - ], - ); - } - - final pathList = hiddenPaths.toList()..sort(); - return ListView( - children: [ - const _Header(), - const Divider(), - ...pathList.map((pathFilter) => ListTile( - title: Text(pathFilter.path), - dense: true, - trailing: IconButton( - icon: const Icon(AIcons.clear), - onPressed: () { - context.read().changeFilterVisibility({pathFilter}, true); - }, - tooltip: context.l10n.removeTooltip, - ), - )), - ], - ); - }, - ), - ), - ); - } -} - -class _Header extends StatelessWidget { - const _Header({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Row( - children: [ - const Icon(AIcons.info), - const SizedBox(width: 16), - Expanded(child: Text(context.l10n.settingsHiddenPathsBanner)), - ], - ), - ); - } -} diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 701ef4860..d00919fc2 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -1,4 +1,5 @@ import 'package:aves/app_flavor.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; @@ -6,8 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/privacy/access_grants.dart'; -import 'package:aves/widgets/settings/privacy/hidden_filters.dart'; -import 'package:aves/widgets/settings/privacy/hidden_paths.dart'; +import 'package:aves/widgets/settings/privacy/hidden_items.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -63,9 +63,8 @@ class PrivacySection extends StatelessWidget { title: Text(context.l10n.settingsSaveSearchHistory), ), ), - const HiddenFilterTile(), - const HiddenPathTile(), - const StorageAccessTile(), + const HiddenItemsTile(), + if (device.canGrantDirectoryAccess) const StorageAccessTile(), ], ); } diff --git a/lib/widgets/settings/viewer/overlay.dart b/lib/widgets/settings/viewer/overlay.dart new file mode 100644 index 000000000..38c85808f --- /dev/null +++ b/lib/widgets/settings/viewer/overlay.dart @@ -0,0 +1,91 @@ +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class ViewerOverlayTile extends StatelessWidget { + const ViewerOverlayTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(context.l10n.settingsViewerOverlayTile), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: ViewerOverlayPage.routeName), + builder: (context) => const ViewerOverlayPage(), + ), + ); + }, + ); + } +} + +class ViewerOverlayPage extends StatelessWidget { + static const routeName = '/settings/viewer_overlay'; + + const ViewerOverlayPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.settingsViewerOverlayTitle), + ), + body: SafeArea( + child: ListView( + children: [ + Selector( + selector: (context, s) => s.showOverlayOnOpening, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayOnOpening = v, + title: Text(context.l10n.settingsViewerShowOverlayOnOpening), + ), + ), + Selector( + selector: (context, s) => s.showOverlayMinimap, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayMinimap = v, + title: Text(context.l10n.settingsViewerShowMinimap), + ), + ), + Selector( + selector: (context, s) => s.showOverlayInfo, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.showOverlayInfo = v, + title: Text(context.l10n.settingsViewerShowInformation), + subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle), + ), + ), + Selector>( + selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails), + builder: (context, s, child) { + final showInfo = s.item1; + final current = s.item2; + return SwitchListTile( + value: current, + onChanged: showInfo ? (v) => settings.showOverlayShootingDetails = v : null, + title: Text(context.l10n.settingsViewerShowShootingDetails), + ); + }, + ), + Selector( + selector: (context, s) => s.enableOverlayBlurEffect, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.enableOverlayBlurEffect = v, + title: Text(context.l10n.settingsViewerEnableOverlayBlurEffect), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings/viewer/viewer.dart b/lib/widgets/settings/viewer/viewer.dart index e06bb572b..ec62009e8 100644 --- a/lib/widgets/settings/viewer/viewer.dart +++ b/lib/widgets/settings/viewer/viewer.dart @@ -7,10 +7,10 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/viewer/entry_background.dart'; +import 'package:aves/widgets/settings/viewer/overlay.dart'; import 'package:aves/widgets/settings/viewer/viewer_actions_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class ViewerSection extends StatelessWidget { final ValueNotifier expandedNotifier; @@ -32,52 +32,16 @@ class ViewerSection extends StatelessWidget { showHighlight: false, children: [ const ViewerActionsTile(), - Selector( - selector: (context, s) => s.showOverlayOnOpening, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayOnOpening = v, - title: Text(context.l10n.settingsViewerShowOverlayOnOpening), - ), - ), - Selector( - selector: (context, s) => s.showOverlayMinimap, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayMinimap = v, - title: Text(context.l10n.settingsViewerShowMinimap), - ), - ), - Selector( - selector: (context, s) => s.showOverlayInfo, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.showOverlayInfo = v, - title: Text(context.l10n.settingsViewerShowInformation), - subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle), - ), - ), - Selector>( - selector: (context, s) => Tuple2(s.showOverlayInfo, s.showOverlayShootingDetails), - builder: (context, s, child) { - final showInfo = s.item1; - final current = s.item2; - return SwitchListTile( - value: current, - onChanged: showInfo ? (v) => settings.showOverlayShootingDetails = v : null, - title: Text(context.l10n.settingsViewerShowShootingDetails), - ); - }, - ), - Selector( - selector: (context, s) => s.enableOverlayBlurEffect, - builder: (context, current, child) => SwitchListTile( - value: current, - onChanged: (v) => settings.enableOverlayBlurEffect = v, - title: Text(context.l10n.settingsViewerEnableOverlayBlurEffect), - ), - ), + const ViewerOverlayTile(), const _CutoutModeSwitch(), + Selector( + selector: (context, s) => s.viewerMaxBrightness, + builder: (context, current, child) => SwitchListTile( + value: current, + onChanged: (v) => settings.viewerMaxBrightness = v, + title: Text(context.l10n.settingsViewerMaximumBrightness), + ), + ), Selector( selector: (context, s) => s.imageBackground, builder: (context, current, child) => ListTile( diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index f80232d90..60ac3c422 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -56,7 +56,7 @@ class StatsPage extends StatelessWidget { entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; } } - entry.xmpSubjects.forEach((tag) { + entry.tags.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; }); }); diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index 7089a8581..68c814511 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -5,6 +5,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/viewer/debug/db.dart'; import 'package:aves/widgets/viewer/debug/metadata.dart'; import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -59,6 +60,7 @@ class ViewerDebugPage extends StatelessWidget { children: [ InfoRowGroup( info: { + 'hash': '#${shortHash(entry)}', 'uri': entry.uri, 'contentId': '${entry.contentId}', 'path': entry.path ?? '', @@ -74,6 +76,7 @@ class ViewerDebugPage extends StatelessWidget { const Divider(), InfoRowGroup( info: { + 'catalogDateMillis': toDateValue(entry.catalogDateMillis), 'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000), 'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis), 'bestDate': '${entry.bestDate}', @@ -112,9 +115,10 @@ class ViewerDebugPage extends StatelessWidget { 'isGeotiff': '${entry.isGeotiff}', 'is360': '${entry.is360}', 'canEdit': '${entry.canEdit}', - 'canEditExif': '${entry.canEditExif}', + 'canEditDate': '${entry.canEditDate}', + 'canEditTags': '${entry.canEditTags}', 'canRotateAndFlip': '${entry.canRotateAndFlip}', - 'xmpSubjects': '${entry.xmpSubjects}', + 'tags': '${entry.tags}', }, ), const Divider(), diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 4ff192481..7e79b672e 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; @@ -20,8 +21,8 @@ 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/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; -import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/viewer/debug/debug_page.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; @@ -124,21 +125,24 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final name = result.item2; if (name.isEmpty) return; - unawaited(androidAppService.pinToHomeScreen(name, entry, uri: entry.uri)); + await androidAppService.pinToHomeScreen(name, entry, uri: entry.uri); + if (!device.showPinShortcutFeedback) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } } Future _flip(BuildContext context, AvesEntry entry) async { if (!await checkStoragePermission(context, {entry})) return; - final success = await entry.flip(persist: _isMainMode(context)); - if (!success) showFeedback(context, context.l10n.genericFailureFeedback); + final dataTypes = await entry.flip(persist: _isMainMode(context)); + if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); } Future _rotate(BuildContext context, AvesEntry entry, {required bool clockwise}) async { if (!await checkStoragePermission(context, {entry})) return; - final success = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context)); - if (!success) showFeedback(context, context.l10n.genericFailureFeedback); + final dataTypes = await entry.rotate(clockwise: clockwise, persist: _isMainMode(context)); + if (dataTypes.isEmpty) showFeedback(context, context.l10n.genericFailureFeedback); } Future _rotateScreen(BuildContext context) async { diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 7725da999..656cb2a53 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math'; +import 'dart:ui'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; @@ -12,6 +13,7 @@ import 'package:aves/widgets/viewer/info/notifications.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class ViewerVerticalPageView extends StatefulWidget { final CollectionLens? collection; @@ -42,6 +44,7 @@ class _ViewerVerticalPageViewState extends State { final ValueNotifier _isVerticallyScrollingNotifier = ValueNotifier(false); Timer? _verticalScrollMonitoringTimer; AvesEntry? _oldEntry; + Future? _systemBrightness; CollectionLens? get collection => widget.collection; @@ -49,10 +52,16 @@ class _ViewerVerticalPageViewState extends State { AvesEntry? get entry => widget.entryNotifier.value; + static const double maximumBrightness = 1.0; + @override void initState() { super.initState(); _registerWidget(widget); + + if (settings.viewerMaxBrightness) { + _systemBrightness = ScreenBrightness().system; + } } @override @@ -144,9 +153,18 @@ class _ViewerVerticalPageViewState extends State { } void _onVerticalPageControllerChanged() { - final opacity = min(1.0, widget.verticalPager.page!); + final page = widget.verticalPager.page!; + + final opacity = min(1.0, page); _backgroundColorNotifier.value = _backgroundColorNotifier.value.withOpacity(opacity * opacity); + if (page <= 1 && settings.viewerMaxBrightness) { + _systemBrightness?.then((system) { + final transition = max(system, lerpDouble(system, maximumBrightness, page / 2)!); + ScreenBrightness().setScreenBrightness(transition); + }); + } + _isVerticallyScrollingNotifier.value = true; _stopScrollMonitoringTimer(); _verticalScrollMonitoringTimer = Timer(Durations.infoScrollMonitoringTimerDelay, () { @@ -169,7 +187,7 @@ class _ViewerVerticalPageViewState extends State { // make sure to locate the entry, // so that we can display the address instead of coordinates // even when initial collection locating has not reached this entry yet - await _entry.catalog(background: false, persist: true, force: false); + await _entry.catalog(background: false, force: false, persist: true); await _entry.locate(background: false, force: false, geocoderLocale: settings.appliedLocale); } else { Navigator.pop(context); diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 523cfec0b..674f55963 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -1,3 +1,4 @@ +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -74,7 +75,9 @@ class VideoConductorProvider extends StatelessWidget { @override Widget build(BuildContext context) { return Provider( - create: (context) => VideoConductor(), + create: (context) => VideoConductor( + persistPlayback: context.read>().value == AppMode.main, + ), dispose: (context, value) => value.dispose(), child: child, ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 35ee00ca6..fbac83b33 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -34,6 +34,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:screen_brightness/screen_brightness.dart'; class EntryViewerStack extends StatefulWidget { final CollectionLens? collection; @@ -83,6 +84,9 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (!settings.viewerUseCutout) { windowService.setCutoutMode(false); } + if (settings.viewerMaxBrightness) { + ScreenBrightness().setScreenBrightness(1); + } if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { windowService.keepScreenOn(true); } @@ -390,7 +394,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (!_isEntryTracked && _verticalPager.page?.floor() == transitionPage) { _trackEntry(); } - _verticalScrollNotifier.notifyListeners(); + _verticalScrollNotifier.notify(); } void _goToCollection(CollectionFilter filter) { @@ -521,6 +525,9 @@ class _EntryViewerStackState extends State with FeedbackMixin, if (!settings.viewerUseCutout) { windowService.setCutoutMode(true); } + if (settings.viewerMaxBrightness) { + ScreenBrightness().resetScreenBrightness(); + } if (settings.keepScreenOn == KeepScreenOn.viewerOnly) { windowService.keepScreenOn(false); } diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index b8d062669..677b1f759 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -1,4 +1,5 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; @@ -9,11 +10,13 @@ import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -22,12 +25,16 @@ import 'package:provider/provider.dart'; class BasicSection extends StatelessWidget { final AvesEntry entry; final CollectionLens? collection; + final EntryInfoActionDelegate actionDelegate; + final ValueNotifier isEditingTagNotifier; final FilterCallback onFilter; const BasicSection({ Key? key, required this.entry, this.collection, + required this.actionDelegate, + required this.isEditingTagNotifier, required this.onFilter, }) : super(key: key); @@ -53,7 +60,7 @@ class BasicSection extends StatelessWidget { final date = entry.bestDate; 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 sizeText = entry.sizeBytes != null ? formatFileSize(locale, entry.sizeBytes!) : infoUnknown; final path = entry.path; return Column( @@ -80,7 +87,7 @@ class BasicSection extends StatelessWidget { } Widget _buildChips(BuildContext context) { - final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); + final tags = entry.tags.toList()..sort(compareAsciiUpperCase); final album = entry.directory; final filters = { MimeFilter(entry.mimeType), @@ -101,19 +108,60 @@ class BasicSection extends StatelessWidget { ...filters, if (entry.isFavourite) FavouriteFilter.instance, ]..sort(); - if (effectiveFilters.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: effectiveFilters - .map((filter) => AvesFilterChip( - filter: filter, - onTap: onFilter, - )) - .toList(), - ), + + final children = [ + ...effectiveFilters.map((filter) => AvesFilterChip( + filter: filter, + onTap: onFilter, + )), + if (actionDelegate.canApply(EntryInfoAction.editTags)) _buildEditTagButton(context), + ]; + + return children.isEmpty + ? const SizedBox() + : Padding( + padding: const EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: children, + ), + ); + }, + ); + } + + Widget _buildEditTagButton(BuildContext context) { + const action = EntryInfoAction.editTags; + return ValueListenableBuilder( + valueListenable: isEditingTagNotifier, + builder: (context, isEditing, child) { + return Stack( + children: [ + DecoratedBox( + decoration: const BoxDecoration( + border: Border.fromBorderSide(BorderSide( + color: AvesFilterChip.defaultOutlineColor, + width: AvesFilterChip.outlineWidth, + )), + borderRadius: BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)), + ), + child: IconButton( + icon: const Icon(AIcons.addTag), + onPressed: isEditing ? null : () => actionDelegate.onActionSelected(context, action), + tooltip: action.getText(context), + ), + ), + if (isEditing) + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(1.0), + child: CircularProgressIndicator( + strokeWidth: AvesFilterChip.outlineWidth, + ), + ), + ), + ], ); }, ); diff --git a/lib/widgets/viewer/info/entry_info_action_delegate.dart b/lib/widgets/viewer/info/entry_info_action_delegate.dart index da527c6d5..5460b24dc 100644 --- a/lib/widgets/viewer/info/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/info/entry_info_action_delegate.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; +import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_xmp_iptc.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.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'; @@ -14,12 +19,17 @@ import 'package:provider/provider.dart'; class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin { final AvesEntry entry; - const EntryInfoActionDelegate(this.entry); + final StreamController> _eventStreamController = StreamController>.broadcast(); + + Stream> get eventStream => _eventStreamController.stream; + + EntryInfoActionDelegate(this.entry); bool isVisible(EntryInfoAction action) { switch (action) { // general case EntryInfoAction.editDate: + case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: return true; // motion photo @@ -32,7 +42,9 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw switch (action) { // general case EntryInfoAction.editDate: - return entry.canEditExif; + return entry.canEditDate; + case EntryInfoAction.editTags: + return entry.canEditTags; case EntryInfoAction.removeMetadata: return entry.canRemoveMetadata; // motion photo @@ -42,11 +54,15 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw } void onActionSelected(BuildContext context, EntryInfoAction action) async { + _eventStreamController.add(ActionStartedEvent(action)); switch (action) { // general case EntryInfoAction.editDate: await _editDate(context); break; + case EntryInfoAction.editTags: + await _editTags(context); + break; case EntryInfoAction.removeMetadata: await _removeMetadata(context); break; @@ -55,26 +71,37 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context); break; } + _eventStreamController.add(ActionEndedEvent(action)); } bool _isMainMode(BuildContext context) => context.read>().value == AppMode.main; - Future _edit(BuildContext context, Future Function() apply) async { + Future _edit(BuildContext context, Future> Function() apply) async { if (!await checkStoragePermission(context, {entry})) return; + // check before applying, because it relies on provider + // but the widget tree may be disposed if the user navigated away + final isMainMode = _isMainMode(context); + final l10n = context.l10n; final source = context.read(); source?.pauseMonitoring(); - final success = await apply(); - if (success) { - if (_isMainMode(context) && source != null) { - await source.refreshEntry(entry); + + final dataTypes = await apply(); + final success = dataTypes.isNotEmpty; + try { + if (success) { + if (isMainMode && source != null) { + await source.refreshEntry(entry, dataTypes); + } else { + await entry.refresh(background: false, persist: false, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale); + } + showFeedback(context, l10n.genericSuccessFeedback); } else { - await entry.refresh(background: false, persist: false, force: true, geocoderLocale: settings.appliedLocale); + showFeedback(context, l10n.genericFailureFeedback); } - showFeedback(context, l10n.genericSuccessFeedback); - } else { - showFeedback(context, l10n.genericFailureFeedback); + } catch (e, stack) { + await reportService.recordError(e, stack); } source?.resumeMonitoring(); } @@ -86,6 +113,17 @@ class EntryInfoActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAw await _edit(context, () => entry.editDate(modifier)); } + Future _editTags(BuildContext context) async { + final newTagsByEntry = await selectTags(context, {entry}); + if (newTagsByEntry == null) return; + + final newTags = newTagsByEntry[entry] ?? entry.tags; + final currentTags = entry.tags; + if (newTags.length == currentTags.length && newTags.every(currentTags.contains)) return; + + await _edit(context, () => entry.editTags(newTags)); + } + Future _removeMetadata(BuildContext context) async { final types = await selectMetadataToRemove(context, {entry}); if (types == null) return; diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 94b78e925..d44e8059d 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -13,19 +13,20 @@ import 'package:flutter/scheduler.dart'; class InfoAppBar extends StatelessWidget { final AvesEntry entry; + final EntryInfoActionDelegate actionDelegate; final ValueNotifier> metadataNotifier; final VoidCallback onBackPressed; const InfoAppBar({ Key? key, required this.entry, + required this.actionDelegate, required this.metadataNotifier, required this.onBackPressed, }) : super(key: key); @override Widget build(BuildContext context) { - final actionDelegate = EntryInfoActionDelegate(entry); final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible); return SliverAppBar( diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 1eadb7439..78810f832 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:aves/model/actions/entry_info_actions.dart'; +import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -6,6 +10,7 @@ import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart'; +import 'package:aves/widgets/viewer/info/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; @@ -142,19 +147,57 @@ class _InfoPageContent extends StatefulWidget { } class _InfoPageContentState extends State<_InfoPageContent> { - static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); - + final List _subscriptions = []; + late EntryInfoActionDelegate _actionDelegate; final ValueNotifier> _metadataNotifier = ValueNotifier({}); + final ValueNotifier _isEditingTagNotifier = ValueNotifier(false); + + static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); CollectionLens? get collection => widget.collection; AvesEntry get entry => widget.entry; + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant _InfoPageContent oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.entry != widget.entry) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(_InfoPageContent widget) { + _actionDelegate = EntryInfoActionDelegate(widget.entry); + _subscriptions.add(_actionDelegate.eventStream.listen(_onActionDelegateEvent)); + } + + void _unregisterWidget(_InfoPageContent widget) { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + } + @override Widget build(BuildContext context) { final basicSection = BasicSection( entry: entry, collection: collection, + actionDelegate: _actionDelegate, + isEditingTagNotifier: _isEditingTagNotifier, onFilter: _goToCollection, ); final locationAtTop = widget.split && entry.hasGps; @@ -194,6 +237,7 @@ class _InfoPageContentState extends State<_InfoPageContent> { slivers: [ InfoAppBar( entry: entry, + actionDelegate: _actionDelegate, metadataNotifier: _metadataNotifier, onBackPressed: widget.goToViewer, ), @@ -210,6 +254,18 @@ class _InfoPageContentState extends State<_InfoPageContent> { ); } + void _onActionDelegateEvent(ActionEvent event) { + if (event.action == EntryInfoAction.editTags) { + Future.delayed(Durations.dialogTransitionAnimation).then((_) { + if (event is ActionStartedEvent) { + _isEditingTagNotifier.value = true; + } else if (event is ActionEndedEvent) { + _isEditingTagNotifier.value = false; + } + }); + } + } + void _goToCollection(CollectionFilter filter) { if (collection == null) return; FilterSelectedNotification(filter).dispatch(context); diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart index 8cee96098..2d7f47fe0 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/crs.dart @@ -7,12 +7,14 @@ class XmpCrsNamespace extends XmpNamespace { static final cgbcPattern = RegExp(ns + r':CircularGradientBasedCorrections\[(\d+)\]/(.*)'); static final gbcPattern = RegExp(ns + r':GradientBasedCorrections\[(\d+)\]/(.*)'); + static final mgbcPattern = RegExp(ns + r':MaskGroupBasedCorrections\[(\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 mgbc = >{}; final pbc = >{}; final retouchAreas = >{}; final look = {}; @@ -24,6 +26,7 @@ class XmpCrsNamespace extends XmpNamespace { final hasStructs = extractStruct(prop, lookPattern, look); var hasIndexedStructs = extractIndexedStruct(prop, cgbcPattern, cgbc); hasIndexedStructs |= extractIndexedStruct(prop, gbcPattern, gbc); + hasIndexedStructs |= extractIndexedStruct(prop, mgbcPattern, mgbc); hasIndexedStructs |= extractIndexedStruct(prop, pbcPattern, pbc); hasIndexedStructs |= extractIndexedStruct(prop, retouchAreasPattern, retouchAreas); return hasStructs || hasIndexedStructs; @@ -46,6 +49,11 @@ class XmpCrsNamespace extends XmpNamespace { title: 'Look', struct: look, ), + if (mgbc.isNotEmpty) + XmpStructArrayCard( + title: 'Mask Group Based Corrections', + structByIndex: mgbc, + ), if (pbc.isNotEmpty) XmpStructArrayCard( title: 'Paint Based Corrections', diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index bb0d56cdd..8b724c00d 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -1,9 +1,8 @@ -// cf photoshop:ColorMode -// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; import 'package:flutter/widgets.dart'; +// cf https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/photoshop.md class XmpPhotoshopNamespace extends XmpNamespace { static const ns = 'photoshop'; diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index fa9ec0861..511a52a2f 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -20,6 +20,7 @@ import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -423,14 +424,25 @@ class _ShootingRow extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = context.l10n.localeName; + + final aperture = details.aperture; + final apertureText = aperture != null ? 'ƒ/${NumberFormat('0.0', locale).format(aperture)}' : Constants.overlayUnknown; + + final focalLength = details.focalLength; + final focalLengthText = focalLength != null ? '${NumberFormat('0.#', locale).format(focalLength)} mm' : Constants.overlayUnknown; + + final iso = details.iso; + final isoText = iso != null ? 'ISO$iso' : Constants.overlayUnknown; + return Row( children: [ const DecoratedIcon(AIcons.shooting, shadows: Constants.embossShadows, size: _iconSize), const SizedBox(width: _iconPadding), - Expanded(child: Text(details.aperture ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(apertureText, strutStyle: Constants.overflowStrutStyle)), Expanded(child: Text(details.exposureTime ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), - Expanded(child: Text(details.focalLength ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), - Expanded(child: Text(details.iso ?? Constants.overlayUnknown, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(focalLengthText, strutStyle: Constants.overflowStrutStyle)), + Expanded(child: Text(isoText, strutStyle: Constants.overflowStrutStyle)), ], ); } diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index d209f4f1b..044933971 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -75,7 +75,6 @@ class OverlayTextButton extends StatelessWidget { shape: MaterialStateProperty.all(const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(_borderRadius)), )), - // shape: MaterialStateProperty.all(CircleBorder()), ), child: Text(buttonLabel), ), diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 427ed1a7b..0d255c6a2 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,4 +1,5 @@ import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; @@ -79,7 +80,7 @@ class ViewerTopOverlay extends StatelessWidget { return targetEntry.canRotateAndFlip; case EntryAction.export: case EntryAction.print: - return !targetEntry.isVideo; + return !targetEntry.isVideo && device.canPrint; case EntryAction.openMap: return targetEntry.hasGps; case EntryAction.viewSource: @@ -87,6 +88,7 @@ class ViewerTopOverlay extends StatelessWidget { case EntryAction.rotateScreen: return settings.isRotationLocked; case EntryAction.addShortcut: + return device.canPinShortcut; case EntryAction.copyToClipboard: case EntryAction.edit: case EntryAction.info: diff --git a/lib/widgets/viewer/video/conductor.dart b/lib/widgets/viewer/video/conductor.dart index 0598fc97e..e0882e562 100644 --- a/lib/widgets/viewer/video/conductor.dart +++ b/lib/widgets/viewer/video/conductor.dart @@ -5,9 +5,12 @@ import 'package:collection/collection.dart'; class VideoConductor { final List _controllers = []; + final bool persistPlayback; static const maxControllerCount = 3; + VideoConductor({required this.persistPlayback}); + Future dispose() async { await Future.forEach(_controllers, (controller) => controller.dispose()); _controllers.clear(); @@ -18,7 +21,7 @@ class VideoConductor { if (controller != null) { _controllers.remove(controller); } else { - controller = IjkPlayerAvesVideoController(entry); + controller = IjkPlayerAvesVideoController(entry, persistPlayback: persistPlayback); } _controllers.insert(0, controller); while (_controllers.length > maxControllerCount) { diff --git a/lib/widgets/viewer/video/controller.dart b/lib/widgets/viewer/video/controller.dart index f3bccd193..21e1c69b7 100644 --- a/lib/widgets/viewer/video/controller.dart +++ b/lib/widgets/viewer/video/controller.dart @@ -11,10 +11,11 @@ import 'package:flutter/material.dart'; abstract class AvesVideoController { final AvesEntry _entry; + final bool persistPlayback; AvesEntry get entry => _entry; - AvesVideoController(AvesEntry entry) : _entry = entry; + AvesVideoController(AvesEntry entry, {required this.persistPlayback}) : _entry = entry; static const resumeTimeSaveMinProgress = .05; static const resumeTimeSaveMaxProgress = .95; @@ -29,16 +30,18 @@ abstract class AvesVideoController { 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}); + if (persistPlayback) { + final _progress = progress; + if (resumeTimeSaveMinProgress < _progress && _progress < resumeTimeSaveMaxProgress) { + await metadataDb.addVideoPlayback({ + VideoPlaybackRow( + contentId: contentId, + resumeTimeMillis: currentPosition, + ) + }); + } else { + await metadataDb.removeVideoPlayback({contentId}); + } } } @@ -46,6 +49,8 @@ abstract class AvesVideoController { final contentId = entry.contentId; if (contentId == null) return null; + if (!persistPlayback) return null; + final playback = await metadataDb.loadVideoPlayback(contentId); final resumeTime = playback?.resumeTimeMillis ?? 0; if (resumeTime == 0) return null; diff --git a/lib/widgets/viewer/video/fijkplayer.dart b/lib/widgets/viewer/video/fijkplayer.dart index 6db787e10..50d1c2b5e 100644 --- a/lib/widgets/viewer/video/fijkplayer.dart +++ b/lib/widgets/viewer/video/fijkplayer.dart @@ -55,7 +55,13 @@ class IjkPlayerAvesVideoController extends AvesVideoController { static const gifLikeBitRateThreshold = 2 << 18; // 512kB/s (4Mb/s) static const captureFrameEnabled = true; - IjkPlayerAvesVideoController(AvesEntry entry) : super(entry) { + IjkPlayerAvesVideoController( + AvesEntry entry, { + required bool persistPlayback, + }) : super( + entry, + persistPlayback: persistPlayback, + ) { _instance = FijkPlayer(); _valueStream.map((value) => value.videoRenderStart).firstWhere((v) => v, orElse: () => false).then( (started) => canCaptureFrameNotifier.value = captureFrameEnabled && started, @@ -80,7 +86,7 @@ class IjkPlayerAvesVideoController extends AvesVideoController { void _startListening() { _instance.addListener(_onValueChanged); - _subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notifyListeners())); + _subscriptions.add(_valueStream.where((value) => value.state == FijkState.completed).listen((_) => _completedNotifier.notify())); _subscriptions.add(_instance.onTimedText.listen(_timedTextStreamController.add)); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index e3a9e153d..0d9666b5f 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; @@ -22,6 +23,7 @@ import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart'; import 'package:aves/widgets/viewer/visual/vector.dart'; import 'package:aves/widgets/viewer/visual/video.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -46,6 +48,16 @@ class _EntryPageViewState extends State { late ValueNotifier _viewStateNotifier; late MagnifierController _magnifierController; final List _subscriptions = []; + ImageStream? _videoCoverStream; + late ImageStreamListener _videoCoverStreamListener; + final ValueNotifier _videoCoverInfoNotifier = ValueNotifier(null); + + MagnifierController? _dismissedCoverMagnifierController; + + MagnifierController get dismissedCoverMagnifierController { + _dismissedCoverMagnifierController ??= MagnifierController(); + return _dismissedCoverMagnifierController!; + } AvesEntry get mainEntry => widget.mainEntry; @@ -58,7 +70,7 @@ class _EntryPageViewState extends State { @override void initState() { super.initState(); - _registerWidget(); + _registerWidget(widget); } @override @@ -66,26 +78,35 @@ class _EntryPageViewState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.pageEntry != widget.pageEntry) { - _unregisterWidget(); - _registerWidget(); + _unregisterWidget(oldWidget); + _registerWidget(widget); } } @override void dispose() { - _unregisterWidget(); + _unregisterWidget(widget); widget.onDisposed?.call(); super.dispose(); } - void _registerWidget() { + void _registerWidget(EntryPageView widget) { + final entry = widget.pageEntry; _viewStateNotifier = context.read().getOrCreateController(entry); _magnifierController = MagnifierController(); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); + if (entry.isVideo) { + _videoCoverStreamListener = ImageStreamListener((image, _) => _videoCoverInfoNotifier.value = image); + _videoCoverStream = entry.uriImage.resolve(ImageConfiguration.empty); + _videoCoverStream!.addListener(_videoCoverStreamListener); + } } - void _unregisterWidget() { + void _unregisterWidget(EntryPageView oldWidget) { + _videoCoverStream?.removeListener(_videoCoverStreamListener); + _videoCoverStream = null; + _videoCoverInfoNotifier.value = null; _magnifierController.dispose(); _subscriptions ..forEach((sub) => sub.cancel()) @@ -163,73 +184,110 @@ class _EntryPageViewState extends State { Widget _buildVideoView() { final videoController = context.read().getController(entry); if (videoController == null) return const SizedBox(); - return Stack( - fit: StackFit.expand, - children: [ - ValueListenableBuilder( - valueListenable: videoController.sarNotifier, - builder: (context, sar, child) { - return Stack( - children: [ - _buildMagnifier( - displaySize: entry.videoDisplaySize(sar), - child: VideoView( - entry: entry, - controller: videoController, - ), + return ValueListenableBuilder( + valueListenable: videoController.sarNotifier, + builder: (context, sar, child) { + final videoDisplaySize = entry.videoDisplaySize(sar); + return Stack( + fit: StackFit.expand, + children: [ + Stack( + children: [ + _buildMagnifier( + displaySize: videoDisplaySize, + child: VideoView( + entry: entry, + controller: videoController, ), + ), + VideoSubtitles( + controller: videoController, + viewStateNotifier: _viewStateNotifier, + ), + if (settings.videoShowRawTimedText) VideoSubtitles( controller: videoController, viewStateNotifier: _viewStateNotifier, + debugMode: true, ), - if (settings.videoShowRawTimedText) - VideoSubtitles( - controller: videoController, - viewStateNotifier: _viewStateNotifier, - debugMode: true, + ], + ), + _buildVideoCover(videoController, videoDisplaySize), + ], + ); + }, + ); + } + + StreamBuilder _buildVideoCover(AvesVideoController videoController, Size videoDisplaySize) { + // fade out image to ease transition with the player + return StreamBuilder( + stream: videoController.statusStream, + builder: (context, snapshot) { + final showCover = !videoController.isReady; + return IgnorePointer( + ignoring: !showCover, + child: AnimatedOpacity( + opacity: showCover ? 1 : 0, + curve: Curves.easeInCirc, + duration: Durations.viewerVideoPlayerTransition, + child: ValueListenableBuilder( + valueListenable: _videoCoverInfoNotifier, + builder: (context, videoCoverInfo, child) { + if (videoCoverInfo != null) { + // full cover image may have a different size and different aspect ratio + final coverSize = Size( + videoCoverInfo.image.width.toDouble(), + videoCoverInfo.image.height.toDouble(), + ); + // when the cover is the same size as the video itself + // (which is often the case when the cover is not embedded but just a frame), + // we can reuse the same magnifier and preserve its state when switching from cover to video + final coverController = showCover || coverSize == videoDisplaySize ? _magnifierController : dismissedCoverMagnifierController; + return _buildMagnifier( + controller: coverController, + displaySize: coverSize, + child: Image( + image: entry.uriImage, ), - ], - ); - }), - // fade out image to ease transition with the player - StreamBuilder( - stream: videoController.statusStream, - builder: (context, snapshot) { - final showCover = !videoController.isReady; - return IgnorePointer( - ignoring: !showCover, - child: AnimatedOpacity( - opacity: showCover ? 1 : 0, - curve: Curves.easeInCirc, - duration: Durations.viewerVideoPlayerTransition, - child: GestureDetector( - onTap: _onTap, - child: ThumbnailImage( - entry: entry, - extent: context.select((mq) => mq.size.shortestSide), - fit: BoxFit.contain, - showLoadingBackground: false, - ), - ), - ), - ); - }, - ), - ], + ); + } + + // default to cached thumbnail, if any + final extent = entry.cachedThumbnails.firstOrNull?.key.extent; + if (extent != null && extent > 0) { + return GestureDetector( + onTap: _onTap, + child: ThumbnailImage( + entry: entry, + extent: extent, + fit: BoxFit.contain, + showLoadingBackground: false, + ), + ); + } + + return const SizedBox(); + }, + ), + ), + ); + }, ); } Widget _buildMagnifier({ + MagnifierController? controller, + Size? displaySize, ScaleLevel maxScale = maxScale, ScaleStateCycle scaleStateCycle = defaultScaleStateCycle, bool applyScale = true, - Size? displaySize, required Widget child, }) { return Magnifier( // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), - controller: _magnifierController, + controller: controller ?? _magnifierController, childSize: displaySize ?? entry.displaySize, minScale: minScale, maxScale: maxScale, diff --git a/plugins/aves_report_crashlytics/pubspec.lock b/plugins/aves_report_crashlytics/pubspec.lock index 22d18caae..fd1f873ab 100644 --- a/plugins/aves_report_crashlytics/pubspec.lock +++ b/plugins/aves_report_crashlytics/pubspec.lock @@ -115,7 +115,7 @@ packages: source: sdk version: "0.0.99" stack_trace: - dependency: transitive + dependency: "direct main" description: name: stack_trace url: "https://pub.dartlang.org" diff --git a/plugins/aves_report_crashlytics/pubspec.yaml b/plugins/aves_report_crashlytics/pubspec.yaml index c0ba30e16..6e756d446 100644 --- a/plugins/aves_report_crashlytics/pubspec.yaml +++ b/plugins/aves_report_crashlytics/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: path: ../aves_report firebase_core: firebase_crashlytics: + stack_trace: dev_dependencies: flutter_lints: diff --git a/pubspec.lock b/pubspec.lock index 01702bd34..7302836cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -119,14 +119,14 @@ packages: name: connectivity_plus url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" connectivity_plus_linux: dependency: transitive description: name: connectivity_plus_linux url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" connectivity_plus_macos: dependency: transitive description: @@ -140,7 +140,7 @@ packages: name: connectivity_plus_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" connectivity_plus_web: dependency: transitive description: @@ -196,7 +196,7 @@ packages: name: dbus url: "https://pub.dartlang.org" source: hosted - version: "0.5.6" + version: "0.6.6" decorated_icon: dependency: "direct main" description: @@ -340,7 +340,7 @@ packages: name: flex_color_picker url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.2.0" fluster: dependency: "direct main" description: @@ -404,7 +404,7 @@ packages: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "2.0.5" flutter_staggered_animations: dependency: "direct main" description: @@ -468,7 +468,7 @@ packages: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" google_maps_flutter_platform_interface: dependency: transitive description: @@ -573,7 +573,7 @@ packages: name: markdown url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "4.0.1" matcher: dependency: transitive description: @@ -629,7 +629,7 @@ packages: name: nm url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.4.1" node_preamble: dependency: transitive description: @@ -727,7 +727,7 @@ packages: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.2" path_provider_platform_interface: dependency: transitive description: @@ -741,14 +741,14 @@ packages: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.3" + version: "2.0.4" pdf: dependency: "direct main" description: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "3.6.1" + version: "3.6.3" pedantic: dependency: transitive description: @@ -769,7 +769,7 @@ packages: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "8.2.6" + version: "8.3.0" permission_handler_platform_interface: dependency: transitive description: @@ -818,7 +818,7 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "5.6.0" + version: "5.6.3" process: dependency: transitive description: @@ -861,12 +861,40 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1+1" + screen_brightness: + dependency: "direct main" + description: + name: screen_brightness + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1" + screen_brightness_platform_interface: + dependency: transitive + description: + name: screen_brightness_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.2" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.dartlang.org" source: hosted + version: "2.0.9" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted version: "2.0.8" shared_preferences_linux: dependency: transitive @@ -874,7 +902,7 @@ packages: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" shared_preferences_macos: dependency: transitive description: @@ -902,7 +930,7 @@ packages: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "2.0.3" shelf: dependency: transitive description: @@ -972,7 +1000,7 @@ packages: source: hosted version: "2.0.1+1" stack_trace: - dependency: "direct main" + dependency: transitive description: name: stack_trace url: "https://pub.dartlang.org" @@ -997,7 +1025,7 @@ packages: description: path: "." ref: HEAD - resolved-ref: d644fedd9cb79a45b1b92788880e81b846a69d9b + resolved-ref: fba50f0e380d8cbd6a5bbda32f97a9c5e4d033e2 url: "git://github.com/deckerst/aves_streams_channel.git" source: git version: "0.3.0" @@ -1084,7 +1112,7 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "6.0.12" + version: "6.0.15" url_launcher_linux: dependency: transitive description: @@ -1175,7 +1203,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.2.10" + version: "2.3.1" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d09074b41..750ecb087 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.5.6+60 +version: 1.5.7+61 publish_to: none environment: @@ -57,9 +57,9 @@ dependencies: permission_handler: printing: provider: + screen_brightness: shared_preferences: sqflite: - stack_trace: streams_channel: git: url: git://github.com/deckerst/aves_streams_channel.git @@ -117,3 +117,30 @@ flutter: # capture shaders in profile mode (real device only): # % flutter drive --flavor play -t test_driver/driver_play.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json + +################################################################################ +# Adaptations + +# `DraggableScrollbar` in `/widgets/common/basic/draggable_scrollbar.dart` +# adapts from package `draggable_scrollbar` v0.0.4 +# +# `Magnifier` in `/widgets/common/magnifier/magnifier.dart` +# adapts from package `photo_view` v0.9.2 +# +# `AvesHighlightView` in `/widgets/common/aves_highlight.dart` +# adapts from package `flutter_highlight` v0.7.0 +# +# `OutputBuffer` in `/services/common/output_buffer.dart` +# adapts from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart` +# +# `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart` +# adapts from Flutter `ScaleGestureRecognizer` in `/gestures/scale.dart` +# +# `TransitionImage` in `/widgets/common/fx/transition_image.dart` +# adapts from Flutter `RawImage` in `/widgets/basic.dart` and `DecorationImagePainter` in `/painting/decoration_image.dart` +# +# `_RenderSliverKnownExtentBoxAdaptor` in `/widgets/common/grid/sliver.dart` +# adapts from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart` +# +# `CollectionSearchDelegate`, `SearchPageRoute` in `/widgets/search/search_delegate.dart` +# adapts from Flutter `SearchDelegate`, `_SearchPageRoute` in `/material/search.dart` diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index d34d41d9e..40042d0f8 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -13,7 +13,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future init() => SynchronousFuture(null); @override - Future removeIds(Set contentIds, {required bool metadataOnly}) => SynchronousFuture(null); + Future removeIds(Set contentIds, {Set? dataTypes}) => SynchronousFuture(null); // entries diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 916df6701..914c82ead 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -125,10 +125,10 @@ void main() { longitude: australiaLatLng.longitude, ), ); - expect(image1.xmpSubjects, []); + expect(image1.tags, {}); final source = await _initSource(); - expect(image1.xmpSubjects, [aTag]); + expect(image1.tags, {aTag}); expect(image1.addressDetails, australiaAddress.copyWith(contentId: image1.contentId)); expect(source.visibleEntries.length, 0); diff --git a/test/utils/file_utils_test.dart b/test/utils/file_utils_test.dart new file mode 100644 index 000000000..71aca0793 --- /dev/null +++ b/test/utils/file_utils_test.dart @@ -0,0 +1,13 @@ +import 'package:aves/utils/file_utils.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:test/test.dart'; + +void main() { + test('format file size', () { + final l10n = lookupAppLocalizations(AppLocalizations.supportedLocales.first); + final locale = l10n.localeName; + expect(formatFileSize(locale, 1024), '1.00 KB'); + expect(formatFileSize(locale, 1536), '1.50 KB'); + expect(formatFileSize(locale, 1073741824), '1.00 GB'); + }); +} diff --git a/test/utils/geo_utils_test.dart b/test/utils/geo_utils_test.dart index 2c3a87eb7..5f9ec8dac 100644 --- a/test/utils/geo_utils_test.dart +++ b/test/utils/geo_utils_test.dart @@ -12,6 +12,9 @@ void main() { 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']); + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(0, 0), minuteSecondPadding: true), ['0° 00′ 00.00″ N', '0° 00′ 00.00″ E']); + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(0, 0), secondDecimals: 0), ['0° 0′ 0″ N', '0° 0′ 0″ E']); + expect(ExtraCoordinateFormat.toDMS(l10n, LatLng(0, 0), secondDecimals: 4), ['0° 0′ 0.0000″ N', '0° 0′ 0.0000″ E']); }); test('bounds center', () { diff --git a/untranslated.json b/untranslated.json index 9e26dfeeb..353479079 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,10 @@ -{} \ No newline at end of file +{ + "ru": [ + "resetButtonTooltip", + "entryInfoActionEditTags", + "settingsViewerMaximumBrightness", + "tagEditorPageTitle", + "tagEditorPageNewTagFieldLabel", + "tagEditorPageAddTagTooltip" + ] +} diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 510e4b891..3f244e721 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,11 +1,6 @@ Thanks for using Aves! -In v1.5.6: -- fixed video playback ignoring hardware-accelerated codecs on recent devices -- partially fixed deleted files leaving ghost items on some devices -- you can now create shortcuts to a specific media item, not only collections -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. +In v1.5.7: +- add and remove tags to JPEG/GIF/PNG/TIFF images +- enjoy the app in French +- KitKat support Full changelog available on GitHub \ No newline at end of file