Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-12-01 17:57:20 +09:00
commit 0a4c04b2dd
168 changed files with 4226 additions and 1227 deletions

View file

@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [v1.5.6] - 2021-11-12
### Added ### Added

View file

@ -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. **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 ## Screenshots
@ -55,7 +55,7 @@ At this stage this project does *not* accept PRs, except for translations.
### 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 ### Donations
@ -82,5 +82,10 @@ To run the app:
# flutter run -t lib/main_play.dart --flavor play # 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 [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 [Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -55,9 +55,8 @@ android {
applicationId appId applicationId appId
// minSdkVersion constraints: // minSdkVersion constraints:
// - Flutter & other plugins: 16 // - Flutter & other plugins: 16
// - google_maps_flutter v2.0.5: 20 // - google_maps_flutter v2.1.1: 20
// - Aves native: 19 minSdkVersion 19
minSdkVersion 20
targetSdkVersion 31 targetSdkVersion 31
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@ -149,7 +148,7 @@ dependencies {
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android // 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' implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.3.0' kapt 'androidx.annotation:annotation:1.3.0'

View file

@ -4,21 +4,12 @@
android:installLocation="auto"> android:installLocation="auto">
<!-- <!--
Scoped storage for primary storage is unusable on Android Q, Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
because users are required to confirm each file to be edited or deleted. So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
These items can only be deleted one by one after catching
a `RecoverableSecurityException` and requesting permission for each.
Android R improvements:
- bulk changes (e.g. `createDeleteRequest`):
https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
--> -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" android:maxSdkVersion="29"
@ -34,6 +25,9 @@
<!-- for API < 26 --> <!-- for API < 26 -->
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" /> <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<!-- allow install on API 19, but Google Maps is from API 20 -->
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
<!-- from Android R, we should define <queries> to make other apps visible to this app --> <!-- from Android R, we should define <queries> to make other apps visible to this app -->
<queries> <queries>
<intent> <intent>

View file

@ -23,7 +23,6 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.util.*
class AnalysisService : MethodChannel.MethodCallHandler, Service() { class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null private var backgroundFlutterEngine: FlutterEngine? = null
@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis // 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, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
getString(R.string.analysis_notification_action_stop), getString(R.string.analysis_notification_action_stop),
stopServiceIntent stopServiceIntent
).build() ).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) return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title)) .setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
.setContentText(message) .setContentText(message)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE) .setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(icon)
.setContentIntent(openAppIntent) .setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction) .addAction(stopAction)

View file

@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler) MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(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, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
DELETE_SINGLE_PERMISSION_REQUEST, DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST, CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST, OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
} }
} }
@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() {
return return
} }
// save access permissions across reboots if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val takeFlags = (data.flags // save access permissions across reboots
and (Intent.FLAG_GRANT_READ_URI_PERMISSION val takeFlags = (data.flags
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) and (Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(treeUri, takeFlags) or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
}
// resume pending action // resume pending action
onStorageAccessResult(requestCode, treeUri) onStorageAccessResult(requestCode, treeUri)
@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() {
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_MAIN -> { Intent.ACTION_MAIN -> {
intent.getStringExtra("page")?.let { page -> intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
var filters = intent.getStringArrayExtra("filters")?.toList() var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
if (filters == null) { if (filters == null) {
// fallback for shortcuts created on API < 26 // fallback for shortcuts created on API < 26
val filterString = intent.getStringExtra("filtersString") val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
if (filterString != null) { if (filterString != null) {
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR) filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
} }
} }
return hashMapOf( return hashMapOf(
"page" to page, INTENT_DATA_KEY_PAGE to page,
"filters" to filters, INTENT_DATA_KEY_FILTERS to filters,
) )
} }
} }
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> { Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri -> (intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
return hashMapOf( return hashMapOf(
"action" to "view", INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
"uri" to uri.toString(), INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional
"mimeType" to intent.type, // MIME type is optional INTENT_DATA_KEY_URI to uri.toString(),
) )
} }
} }
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> { Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf( return hashMapOf(
"action" to "pick", INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
"mimeType" to intent.type, INTENT_DATA_KEY_MIME_TYPE to intent.type,
) )
} }
Intent.ACTION_SEARCH -> { Intent.ACTION_SEARCH -> {
val viewUri = intent.dataString val viewUri = intent.dataString
return if (viewUri != null) hashMapOf( return if (viewUri != null) hashMapOf(
"action" to "view", INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
"uri" to viewUri, INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
"mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY), INTENT_DATA_KEY_URI to viewUri,
) else hashMapOf( ) else hashMapOf(
"action" to "search", INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
"query" to intent.getStringExtra(SearchManager.QUERY), INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
) )
} }
Intent.ACTION_RUN -> { 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)) .setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent( .setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/search") .putExtra(SHORTCUT_KEY_PAGE, "/search")
) )
.build() .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)) .setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent( .setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/collection") .putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")) .putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
) )
.build() .build()
@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() {
const val OPEN_FROM_ANALYSIS_SERVICE = 2 const val OPEN_FROM_ANALYSIS_SERVICE = 2
const val CREATE_FILE_REQUEST = 3 const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4 const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5 const val DELETE_SINGLE_PERMISSION_REQUEST = 5
const val DELETE_SINGLE_PERMISSION_REQUEST = 6 const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
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 // request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>() val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()

View file

@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false var removed = false
try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f try {
} catch (e: Exception) { removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
Log.w(LOG_TAG, "failed to get settings", e) } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
} }
result.success(removed) result.success(removed)
} }

View file

@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
} }
// can be null or empty // can be null or empty
val contentIds = call.argument<List<Int>>("contentIds"); val contentIds = call.argument<List<Int>>("contentIds")
if (!activity.isMyServiceRunning(AnalysisService::class.java)) { if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java) val intent = Intent(activity, AnalysisService::class.java)

View file

@ -18,6 +18,10 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity 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.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend 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) "openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs) "setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share) "share" -> safe(call, result, ::share)
"canPin" -> safe(call, result, ::canPin) "pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
fun addPackageDetails(intent: Intent) { fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating directories // apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale // 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 val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) { for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// shortcuts // shortcuts
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(isPinSupported())
}
private fun pin(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label") val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes") val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters") val filters = call.argument<List<String>>("filters")
@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return return
} }
if (!isPinSupported()) { if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null) result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return return
} }
@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = when { val intent = when {
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java) uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java) filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra("page", "/collection") .putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra("filters", filters.toTypedArray()) .putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut // on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback // 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 -> { else -> {
result.error("pin-intent", "failed to build intent", null) result.error("pin-intent", "failed to build intent", null)
return return

View file

@ -1,21 +1,42 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context
import android.os.Build import android.os.Build
import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.* import java.util.*
class DeviceHandler : MethodCallHandler { class DeviceHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented() 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) { private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id) result.success(TimeZone.getDefault().id)
} }

View file

@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) } "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) } "removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented() 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<List<FieldMap>>("iptc")
val entryMap = call.argument<FieldMap>("entry")
val postEditScan = call.argument<Boolean>("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<String>("xmp")
val extendedXmp = call.argument<String>("extendedXmp")
val entryMap = call.argument<FieldMap>("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) { private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument<List<String>>("types") val types = call.argument<List<String>>("types")
val entryMap = call.argument<FieldMap>("entry") val entryMap = call.argument<FieldMap>("entry")

View file

@ -10,6 +10,8 @@ import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException 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.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.KeyValuePair import com.drew.lang.KeyValuePair
@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.ParseException import java.text.ParseException
import java.util.* import java.util.*
@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } "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) } "hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
else -> result.notImplemented() else -> result.notImplemented()
@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val kv = pair as KeyValuePair val kv = pair as KeyValuePair
val key = kv.key val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1 // `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 valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString) val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) { if (dirs?.any() == true) {
@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try { 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)) { if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
} }
@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return return
} }
val saveExposureTime: (value: Rational) -> Unit = { val saveExposureTime = fun(value: Rational) {
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal // `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) // 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 // and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator val num = value.numerator
val denom = it.denominator val denom = value.denominator
metadataMap[KEY_EXPOSURE_TIME] = when { 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() 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) 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<String>("mimeType")
val uri = call.argument<String>("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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("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) { private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument<String>("prop") val prop = call.argument<String>("prop")
if (prop == null) { if (prop == null) {
@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"XMP", "XMP",
) )
private val xmpSerializeOptions = SerializeOptions().apply {
omitPacketWrapper = true // e.g. <?xpacket begin="..." id="W5M0MpCehiHzreSzNTczkc9d"?>...<?xpacket end="r"?>
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
}
// catalog metadata // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis" private const val KEY_DATE_MILLIS = "dateMillis"

View file

@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
try { try {
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0 locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) { } 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) result.success(locked)
} }

View file

@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -68,7 +69,12 @@ class RegionFetcher internal constructor(
if (currentDecoderRef == null) { if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input -> val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext") @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) { if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null) result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
import android.content.Context import android.content.Context
import android.database.ContentObserver import android.database.ContentObserver
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.provider.Settings
@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
override fun onChange(selfChange: Boolean, uri: Uri?) { override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) { if (update()) {
success( val settings: FieldMap = hashMapOf(
hashMapOf( Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
)
) )
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 accelerometerRotation = newAccelerometerRotation
changed = true changed = true
} }
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
if (transitionAnimationScale != newTransitionAnimationScale) { val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
transitionAnimationScale = newTransitionAnimationScale if (transitionAnimationScale != newTransitionAnimationScale) {
changed = true transitionAnimationScale = newTransitionAnimationScale
changed = true
}
} }
} catch (e: Exception) { } 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 return changed
} }

View file

@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream() else -> endOfStream()
} }
} }
@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
} }
private fun createFile() { 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 name = args["name"] as String?
val mimeType = args["mimeType"] as String? val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray? val bytes = args["bytes"] as ByteArray?
@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
private fun openFile() { 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? val mimeType = args["mimeType"] as String?
if (mimeType == null) { if (mimeType == null) {
error("openFile-args", "failed because of missing arguments", 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) 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?) {} override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) { private fun success(result: Any?) {

View file

@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
val bitmapHeight: Int val bitmapHeight: Int
if (width / height > svgWidth / svgHeight) { if (width / height > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt() bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
bitmapHeight = height; bitmapHeight = height
} else { } else {
bitmapWidth = width bitmapWidth = width
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt() bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()

View file

@ -223,7 +223,9 @@ object ExifInterfaceHelper {
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap() val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
// exclude Exif directory when it only includes image size // exclude Exif directory when it only includes image size
val isUselessExif: (Map<String, String>) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") } val isUselessExif = fun(it: Map<String, String>): Boolean {
return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width")
}
return HashMap<String, Map<String, String>>().apply { return HashMap<String, Map<String, String>>().apply {
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf()) put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())

View file

@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks", MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title", MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height", 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_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer", MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year", MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply { ).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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate") put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
} }

View file

@ -32,12 +32,12 @@ object Metadata {
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata // types of metadata
const val TYPE_COMMENT = "comment"
const val TYPE_EXIF = "exif" const val TYPE_EXIF = "exif"
const val TYPE_ICC_PROFILE = "icc_profile" const val TYPE_ICC_PROFILE = "icc_profile"
const val TYPE_IPTC = "iptc" const val TYPE_IPTC = "iptc"
const val TYPE_JFIF = "jfif" const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe" const val TYPE_JPEG_ADOBE = "jpeg_adobe"
const val TYPE_JPEG_COMMENT = "jpeg_comment"
const val TYPE_JPEG_DUCKY = "jpeg_ducky" const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb" const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp" const val TYPE_XMP = "xmp"

View file

@ -54,9 +54,11 @@ object MultiPage {
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks // 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 // 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_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it } format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
} }

View file

@ -1,17 +1,21 @@
package deckers.thibault.aves.metadata 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_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF 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_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_JPEG_DUCKY
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
import deckers.thibault.aves.model.FieldMap
import pixy.meta.meta.Metadata import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType 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.jpeg.JPGMeta
import pixy.meta.meta.xmp.XMP import pixy.meta.meta.xmp.XMP
import pixy.meta.string.XMLUtils import pixy.meta.string.XMLUtils
@ -50,9 +54,46 @@ object PixyMetaHelper {
return metadataMap return metadataMap
} }
fun getIptc(input: InputStream): List<FieldMap>? {
val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
val iptcDataList = ArrayList<FieldMap>()
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<FieldMap>?,
) {
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<IPTCDataSet>()
Metadata.insertIPTC(input, output, iptc)
}
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP? 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) { if (extendedXmpString != null) {
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString) JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
} else { } else {
@ -70,12 +111,12 @@ object PixyMetaHelper {
} }
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) { private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
TYPE_COMMENT -> MetadataType.COMMENT
TYPE_EXIF -> MetadataType.EXIF TYPE_EXIF -> MetadataType.EXIF
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
TYPE_IPTC -> MetadataType.IPTC TYPE_IPTC -> MetadataType.IPTC
TYPE_JFIF -> MetadataType.JPG_JFIF TYPE_JFIF -> MetadataType.JPG_JFIF
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
TYPE_XMP -> MetadataType.XMP TYPE_XMP -> MetadataType.XMP

View file

@ -23,7 +23,7 @@ object XMP {
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" 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/" 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/" 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/" 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/" private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory import com.drew.metadata.avi.AviDirectory
@ -135,10 +136,12 @@ class SourceEntry {
try { try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it } retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = 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.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = 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) { } catch (e: Exception) {
// ignore // ignore
} finally { } finally {

View file

@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.canEditExif 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.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
@ -460,6 +461,94 @@ abstract class ImageProvider {
return true return true
} }
private fun editIptc(
context: Context,
path: String,
uri: Uri,
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
iptc: List<FieldMap>?,
): 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( private fun editXmp(
context: Context, context: Context,
path: String, path: String,
@ -467,7 +556,9 @@ abstract class ImageProvider {
mimeType: String, mimeType: String,
callback: ImageOpCallback, callback: ImageOpCallback,
trailerDiff: Int = 0, trailerDiff: Int = 0,
edit: (xmp: String) -> String, coreXmp: String? = null,
extendedXmp: String? = null,
editCoreXmp: ((xmp: String) -> String)? = null,
): Boolean { ): Boolean {
if (!canEditXmp(mimeType)) { if (!canEditXmp(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType")) callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
@ -479,18 +570,34 @@ abstract class ImageProvider {
val editableFile = File.createTempFile("aves", null).apply { val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit() deleteOnExit()
try { try {
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } var editedXmpString = coreXmp
if (xmp == null) { var editedExtendedXmp = extendedXmp
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) if (editCoreXmp != null) {
return false 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 -> outputStream().use { output ->
// reopen input to read from start // reopen input to read from start
StorageUtils.openInputStream(context, uri)?.use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val editedXmpString = edit(xmp.xmpDocString()) if (editedXmpString != null) {
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) 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) { } catch (e: Exception) {
@ -538,7 +645,7 @@ abstract class ImageProvider {
"We need to edit XMP to adjust trailer video offset by $diff bytes." "We need to edit XMP to adjust trailer video offset by $diff bytes."
) )
val newTrailerOffset = trailerOffset + diff 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( xmp.replace(
// GCamera motion photo // GCamera motion photo
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"", "${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}=\"$trailerOffset\"",
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"", "${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
) )
} })
} }
fun editOrientation( 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<FieldMap>? = null,
) {
val newFields = HashMap<String, Any?>()
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<String, Any?>()
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( fun removeMetadataTypes(
context: Context, context: Context,
path: String, path: String,

View file

@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() {
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION) 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) { override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
var found = false var found = false
val fetched = arrayListOf<FieldMap>() val fetched = arrayListOf<FieldMap>()
val id = uri.tryParseId() val id = uri.tryParseId()
val onSuccess = fun(entry: FieldMap) { val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
entry["uri"] = uri.toString() val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
fetched.add(entry)
}
val alwaysValid = { _: Int, _: Int -> true }
if (id != null) { if (id != null) {
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) { if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id) val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() {
} }
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> { fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val foundContentIds = ArrayList<Int>() val foundContentIds = HashSet<Int>()
fun check(context: Context, contentUri: Uri) { fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID) val projection = arrayOf(MediaStore.MediaColumns._ID)
try { try {

View file

@ -23,7 +23,7 @@ object FlutterUtils {
} }
lateinit var flutterLoader: FlutterLoader lateinit var flutterLoader: FlutterLoader
FlutterUtils.runOnUiThread { runOnUiThread {
// initialization must happen on the main thread // initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply { flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context) startInitialization(context)

View file

@ -110,7 +110,16 @@ object MimeTypes {
} }
// as of latest PixyMeta // 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 // as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) { fun canRemoveMetadata(mimeType: String) = when (mimeType) {

View file

@ -142,39 +142,6 @@ object PermissionManager {
} }
} }
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
val dirs = ArrayList<Map<String, String>>()
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<FieldMap>): Boolean { fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directories.all { directories.all {
@ -195,12 +162,14 @@ object PermissionManager {
} ?: false } ?: false
} }
// returns paths matching URIs granted by the user // returns paths matching directory URIs granted by the user
fun getGrantedDirs(context: Context): Set<String> { fun getGrantedDirs(context: Context): Set<String> {
val grantedDirs = HashSet<String>() val grantedDirs = HashSet<String>()
for (uriPermission in context.contentResolver.persistedUriPermissions) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri) for (uriPermission in context.contentResolver.persistedUriPermissions) {
dirPath?.let { grantedDirs.add(it) } val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
}
} }
return grantedDirs return grantedDirs
} }
@ -208,13 +177,53 @@ object PermissionManager {
// returns paths accessible to the app (granted by the user or by default) // returns paths accessible to the app (granted by the user or by default)
private fun getAccessibleDirs(context: Context): Set<String> { private fun getAccessibleDirs(context: Context): Set<String> {
val accessibleDirs = HashSet(getGrantedDirs(context)) 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)) accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
} }
return accessibleDirs return accessibleDirs
} }
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
val dirs = ArrayList<Map<String, String>>()
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 // As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
// URI permissions we hold points to a folder that no longer exists, // URI permissions we hold points to a folder that no longer exists,
// so we should remove these obsolete URIs before proceeding. // 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) { private fun releaseUriPermission(context: Context, it: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags) context.contentResolver.releasePersistableUriPermission(it, flags)

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Recherche</string>
<string name="videos_shortcut_short_label">Vidéos</string>
<string name="analysis_channel_name">Analyse des images</string>
<string name="analysis_service_description">Analyse des images &amp; vidéos</string>
<string name="analysis_notification_default_title">Analyse des images</string>
<string name="analysis_notification_action_stop">Annuler</string>
</resources>

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.31' ext.kotlin_version = '1.6.0'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()

View file

@ -2,4 +2,4 @@
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können. <b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
<i>Aves</i> lässt sich mit Android (von <b>API 20 bis 31</b>, d. h. von Lollipop bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>. <i>Aves</i> lässt sich mit Android (von <b>API 19 bis 31</b>, d. h. von KitKat bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.

View file

@ -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

View file

@ -2,4 +2,4 @@
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc. <b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (from <b>API 20 to 31</b>, i.e. from Lollipop to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>. <i>Aves</i> integrates with Android (from <b>API 19 to 31</b>, i.e. from KitKat to S) with features such as <b>app shortcuts</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -53,6 +53,8 @@
"@hideTooltip": {}, "@hideTooltip": {},
"removeTooltip": "Remove", "removeTooltip": "Remove",
"@removeTooltip": {}, "@removeTooltip": {},
"resetButtonTooltip": "Reset",
"@resetButtonTooltip": {},
"doubleBackExitMessage": "Tap “back” again to exit.", "doubleBackExitMessage": "Tap “back” again to exit.",
"@doubleBackExitMessage": {}, "@doubleBackExitMessage": {},
@ -145,6 +147,8 @@
"entryInfoActionEditDate": "Edit date & time", "entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {}, "@entryInfoActionEditDate": {},
"entryInfoActionEditTags": "Edit tags",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Remove metadata", "entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {}, "@entryInfoActionRemoveMetadata": {},
@ -417,7 +421,7 @@
"removeEntryMetadataDialogMore": "More", "removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {}, "@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": {}, "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"videoSpeedDialogLabel": "Playback speed", "videoSpeedDialogLabel": "Playback speed",
@ -501,6 +505,17 @@
"@aboutCreditsWorldAtlas2": {}, "@aboutCreditsWorldAtlas2": {},
"aboutCreditsTranslators": "Translators:", "aboutCreditsTranslators": "Translators:",
"@aboutCreditsTranslators": {}, "@aboutCreditsTranslators": {},
"aboutCreditsTranslatorLine": "{language}: {names}",
"@aboutCreditsTranslatorLine": {
"placeholders": {
"language": {
"type": "String"
},
"names": {
"type": "String"
}
}
},
"aboutLicenses": "Open-Source Licenses", "aboutLicenses": "Open-Source Licenses",
"@aboutLicenses": {}, "@aboutLicenses": {},
@ -646,6 +661,8 @@
"@drawerCollectionImages": {}, "@drawerCollectionImages": {},
"drawerCollectionVideos": "Videos", "drawerCollectionVideos": "Videos",
"@drawerCollectionVideos": {}, "@drawerCollectionVideos": {},
"drawerCollectionAnimated": "Animated",
"@drawerCollectionAnimated": {},
"drawerCollectionMotionPhotos": "Motion photos", "drawerCollectionMotionPhotos": "Motion photos",
"@drawerCollectionMotionPhotos": {}, "@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoramas", "drawerCollectionPanoramas": "Panoramas",
@ -791,20 +808,10 @@
"settingsSectionViewer": "Viewer", "settingsSectionViewer": "Viewer",
"@settingsSectionViewer": {}, "@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": "Use cutout area",
"@settingsViewerUseCutout": {}, "@settingsViewerUseCutout": {},
"settingsViewerMaximumBrightness": "Maximum brightness",
"@settingsViewerMaximumBrightness": {},
"settingsImageBackground": "Image background", "settingsImageBackground": "Image background",
"@settingsImageBackground": {}, "@settingsImageBackground": {},
@ -821,6 +828,23 @@
"settingsViewerQuickActionEmpty": "No buttons", "settingsViewerQuickActionEmpty": "No buttons",
"@settingsViewerQuickActionEmpty": {}, "@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": "Video Settings",
"@settingsVideoPageTitle": {}, "@settingsVideoPageTitle": {},
"settingsSectionVideo": "Video", "settingsSectionVideo": "Video",
@ -880,8 +904,11 @@
"settingsSaveSearchHistory": "Save search history", "settingsSaveSearchHistory": "Save search history",
"@settingsSaveSearchHistory": {}, "@settingsSaveSearchHistory": {},
"settingsHiddenFiltersTile": "Hidden filters", "settingsHiddenItemsTile": "Hidden items",
"@settingsHiddenFiltersTile": {}, "@settingsHiddenItemsTile": {},
"settingsHiddenItemsTitle": "Hidden Items",
"@settingsHiddenItemsTitle": {},
"settingsHiddenFiltersTitle": "Hidden Filters", "settingsHiddenFiltersTitle": "Hidden Filters",
"@settingsHiddenFiltersTitle": {}, "@settingsHiddenFiltersTitle": {},
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.", "settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
@ -889,14 +916,10 @@
"settingsHiddenFiltersEmpty": "No hidden filters", "settingsHiddenFiltersEmpty": "No hidden filters",
"@settingsHiddenFiltersEmpty": {}, "@settingsHiddenFiltersEmpty": {},
"settingsHiddenPathsTile": "Hidden paths",
"@settingsHiddenPathsTile": {},
"settingsHiddenPathsTitle": "Hidden Paths", "settingsHiddenPathsTitle": "Hidden Paths",
"@settingsHiddenPathsTitle": {}, "@settingsHiddenPathsTitle": {},
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.", "settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
"@settingsHiddenPathsBanner": {}, "@settingsHiddenPathsBanner": {},
"settingsHiddenPathsEmpty": "No hidden paths",
"@settingsHiddenPathsEmpty": {},
"addPathTooltip": "Add path", "addPathTooltip": "Add path",
"@addPathTooltip": {}, "@addPathTooltip": {},
@ -1026,11 +1049,31 @@
"viewerInfoSearchSuggestionRights": "Rights", "viewerInfoSearchSuggestionRights": "Rights",
"@viewerInfoSearchSuggestionRights": {}, "@viewerInfoSearchSuggestionRights": {},
"tagEditorPageTitle": "Edit Tags",
"@tagEditorPageTitle": {},
"tagEditorPageNewTagFieldLabel": "New tag",
"@tagEditorPageNewTagFieldLabel": {},
"tagEditorPageAddTagTooltip": "Add tag",
"@tagEditorPageAddTagTooltip": {},
"tagEditorSectionRecent": "Recent",
"@tagEditorSectionRecent": {},
"panoramaEnableSensorControl": "Enable sensor control", "panoramaEnableSensorControl": "Enable sensor control",
"@panoramaEnableSensorControl": {}, "@panoramaEnableSensorControl": {},
"panoramaDisableSensorControl": "Disable sensor control", "panoramaDisableSensorControl": "Disable sensor control",
"@panoramaDisableSensorControl": {}, "@panoramaDisableSensorControl": {},
"sourceViewerPageTitle": "Source", "sourceViewerPageTitle": "Source",
"@sourceViewerPageTitle": {} "@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Show hidden files",
"@filePickerShowHiddenFiles": {},
"filePickerDoNotShowHiddenFiles": "Dont show hidden files",
"@filePickerDoNotShowHiddenFiles": {},
"filePickerOpenFrom": "Open from",
"@filePickerOpenFrom": {},
"filePickerNoItems": "No items",
"@filePickerNoItems": {},
"filePickerUseThisFolder": "Use this folder",
"@filePickerUseThisFolder": {}
} }

523
lib/l10n/app_fr.arb Normal file
View file

@ -0,0 +1,523 @@
{
"appName": "Aves",
"welcomeMessage": "Bienvenue",
"welcomeOptional": "Option",
"welcomeTermsToggle": "Jaccepte les conditions dutilisation",
"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 limage",
"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 lapp 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} despace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.",
"unsupportedTypeDialogTitle": "Formats non supportés",
"unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération nest pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération nest 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 napparaî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 lalbum",
"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 dune 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 ny a pas dautre 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 dAves est disponible sur",
"aboutUpdateLinks2": "et",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "GitHub",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Rapports derreur",
"aboutBugSaveLogInstruction": "Sauvegarder les logs de lapp vers un fichier",
"aboutBugSaveLogButton": "Sauvegarder",
"aboutBugCopyInfoInstruction": "Copier les informations denvironnement",
"aboutBugCopyInfoButton": "Copier",
"aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations denvironnement",
"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 lalbum",
"collectionActionMove": "Déplacer vers lalbum",
"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": "Aujourdhui",
"dateYesterday": "Hier",
"dateThisMonth": "Ce mois-ci",
"collectionDeleteFailureFeedback": "{count, plural, =1{Échec de la suppression d1 élément} other{Échec de la suppression de {count} éléments}}",
"collectionCopyFailureFeedback": "{count, plural, =1{Échec de la copie d1 élément} other{Échec de la copie de {count} éléments}}",
"collectionMoveFailureFeedback": "{count, plural, =1{Échec du déplacement d1 élément} other{Échec du déplacement de {count} éléments}}",
"collectionEditFailureFeedback": "{count, plural, =1{Échec de la modification d1 élément} other{Échec de la modification de {count} éléments}}",
"collectionExportFailureFeedback": "{count, plural, =1{Échec de lexport d1 page} other{Échec de lexport 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 daccueil",
"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 licône de lieu",
"settingsThumbnailShowMotionPhotoIcon": "Afficher licône de photo animée",
"settingsThumbnailShowRawIcon": "Afficher licô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 dencoche",
"settingsViewerMaximumBrightness": "Luminosité maximale",
"settingsImageBackground": "Arrière-plan de limage",
"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 à louverture",
"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 darrière-plan",
"settingsSubtitleThemeBackgroundOpacity": "Transparence de larrière-plan",
"settingsSubtitleThemeTextAlignmentLeft": "gauche",
"settingsSubtitleThemeTextAlignmentCenter": "centré",
"settingsSubtitleThemeTextAlignmentRight": "droite",
"settingsSectionPrivacy": "Confidentialité",
"settingsAllowInstalledAppAccess": "Autoriser laccès à linventaire des apps",
"settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums",
"settingsAllowErrorReporting": "Autoriser lenvoi de rapports derreur",
"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 napparaî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, napparaîtront pas dans votre collection.",
"addPathTooltip": "Ajouter un chemin",
"settingsStorageAccessTile": "Accès au stockage",
"settingsStorageAccessTitle": "Accès au stockage",
"settingsStorageAccessBanner": "Une autorisation daccès au stockage est nécessaire pour modifier le contenu de certains dossiers. Voici la liste des autorisations couramment en vigueur.",
"settingsStorageAccessEmpty": "Aucune autorisation daccè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 nexiste 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 lextraction 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"
}

View file

@ -22,6 +22,7 @@
"showTooltip": "보기", "showTooltip": "보기",
"hideTooltip": "숨기기", "hideTooltip": "숨기기",
"removeTooltip": "제거", "removeTooltip": "제거",
"resetButtonTooltip": "복원",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.", "doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
@ -71,6 +72,7 @@
"videoActionSettings": "설정", "videoActionSettings": "설정",
"entryInfoActionEditDate": "날짜와 시간 수정", "entryInfoActionEditDate": "날짜와 시간 수정",
"entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제", "entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterFavouriteLabel": "즐겨찾기", "filterFavouriteLabel": "즐겨찾기",
@ -186,7 +188,7 @@
"removeEntryMetadataDialogTitle": "메타데이터 삭제", "removeEntryMetadataDialogTitle": "메타데이터 삭제",
"removeEntryMetadataDialogMore": "더 보기", "removeEntryMetadataDialogMore": "더 보기",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?", "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
"videoSpeedDialogLabel": "재생 배속", "videoSpeedDialogLabel": "재생 배속",
@ -232,6 +234,7 @@
"aboutCreditsWorldAtlas1": "이 앱은", "aboutCreditsWorldAtlas1": "이 앱은",
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
"aboutCreditsTranslators": "번역가:", "aboutCreditsTranslators": "번역가:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "오픈 소스 라이선스", "aboutLicenses": "오픈 소스 라이선스",
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
@ -292,6 +295,7 @@
"drawerCollectionFavourites": "즐겨찾기", "drawerCollectionFavourites": "즐겨찾기",
"drawerCollectionImages": "사진", "drawerCollectionImages": "사진",
"drawerCollectionVideos": "동영상", "drawerCollectionVideos": "동영상",
"drawerCollectionAnimated": "애니메이션",
"drawerCollectionMotionPhotos": "모션 포토", "drawerCollectionMotionPhotos": "모션 포토",
"drawerCollectionPanoramas": "파노라마", "drawerCollectionPanoramas": "파노라마",
"drawerCollectionRaws": "Raw 이미지", "drawerCollectionRaws": "Raw 이미지",
@ -372,13 +376,8 @@
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어", "settingsSectionViewer": "뷰어",
"settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
"settingsViewerShowMinimap": "미니맵 표시",
"settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
"settingsViewerUseCutout": "컷아웃 영역 사용", "settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerMaximumBrightness": "최대 밝기",
"settingsImageBackground": "이미지 배경", "settingsImageBackground": "이미지 배경",
"settingsViewerQuickActionsTile": "빠른 작업", "settingsViewerQuickActionsTile": "빠른 작업",
@ -388,6 +387,15 @@
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼", "settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
"settingsViewerQuickActionEmpty": "버튼이 없습니다", "settingsViewerQuickActionEmpty": "버튼이 없습니다",
"settingsViewerOverlayTile": "오버레이",
"settingsViewerOverlayTitle": "오버레이",
"settingsViewerShowOverlayOnOpening": "열릴 때 표시",
"settingsViewerShowMinimap": "미니맵 표시",
"settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "흐림 효과",
"settingsVideoPageTitle": "동영상 설정", "settingsVideoPageTitle": "동영상 설정",
"settingsSectionVideo": "동영상", "settingsSectionVideo": "동영상",
"settingsVideoShowVideos": "미디어에 동영상 표시", "settingsVideoShowVideos": "미디어에 동영상 표시",
@ -419,15 +427,15 @@
"settingsAllowErrorReporting": "오류 보고서 보내기", "settingsAllowErrorReporting": "오류 보고서 보내기",
"settingsSaveSearchHistory": "검색기록", "settingsSaveSearchHistory": "검색기록",
"settingsHiddenFiltersTile": "숨겨진 필터", "settingsHiddenItemsTile": "숨겨진 항목",
"settingsHiddenItemsTitle": "숨겨진 항목",
"settingsHiddenFiltersTitle": "숨겨진 필터", "settingsHiddenFiltersTitle": "숨겨진 필터",
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", "settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다", "settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
"settingsHiddenPathsTile": "숨겨진 경로",
"settingsHiddenPathsTitle": "숨겨진 경로", "settingsHiddenPathsTitle": "숨겨진 경로",
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", "settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
"addPathTooltip": "경로 추가", "addPathTooltip": "경로 추가",
"settingsStorageAccessTile": "저장공간 접근", "settingsStorageAccessTile": "저장공간 접근",
@ -496,8 +504,19 @@
"viewerInfoSearchSuggestionResolution": "해상도", "viewerInfoSearchSuggestionResolution": "해상도",
"viewerInfoSearchSuggestionRights": "권리", "viewerInfoSearchSuggestionRights": "권리",
"tagEditorPageTitle": "태그 수정",
"tagEditorPageNewTagFieldLabel": "새 태그",
"tagEditorPageAddTagTooltip": "태그 추가",
"tagEditorSectionRecent": "최근 이용기록",
"panoramaEnableSensorControl": "센서 제어 활성화", "panoramaEnableSensorControl": "센서 제어 활성화",
"panoramaDisableSensorControl": "센서 제어 비활성화", "panoramaDisableSensorControl": "센서 제어 비활성화",
"sourceViewerPageTitle": "소스 코드" "sourceViewerPageTitle": "소스 코드",
"filePickerShowHiddenFiles": "숨겨진 파일 표시",
"filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함",
"filePickerOpenFrom": "다음에서 열기:",
"filePickerNoItems": "항목 없음",
"filePickerUseThisFolder": "이 폴더 사용"
} }

View file

@ -186,7 +186,7 @@
"removeEntryMetadataDialogTitle": "Удаление метаданных", "removeEntryMetadataDialogTitle": "Удаление метаданных",
"removeEntryMetadataDialogMore": "Дополнительно", "removeEntryMetadataDialogMore": "Дополнительно",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?", "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?",
"videoSpeedDialogLabel": "Скорость воспроизведения", "videoSpeedDialogLabel": "Скорость воспроизведения",
@ -232,6 +232,7 @@
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из", "aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
"aboutCreditsWorldAtlas2": "под лицензией ISC.", "aboutCreditsWorldAtlas2": "под лицензией ISC.",
"aboutCreditsTranslators": "Переводчики:", "aboutCreditsTranslators": "Переводчики:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "Лицензии с открытым исходным кодом", "aboutLicenses": "Лицензии с открытым исходным кодом",
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.", "aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
@ -292,6 +293,7 @@
"drawerCollectionFavourites": "Избранное", "drawerCollectionFavourites": "Избранное",
"drawerCollectionImages": "Изображения", "drawerCollectionImages": "Изображения",
"drawerCollectionVideos": "Видео", "drawerCollectionVideos": "Видео",
"drawerCollectionAnimated": "GIF",
"drawerCollectionMotionPhotos": "Живые фото", "drawerCollectionMotionPhotos": "Живые фото",
"drawerCollectionPanoramas": "Панорамы", "drawerCollectionPanoramas": "Панорамы",
"drawerCollectionRaws": "RAW", "drawerCollectionRaws": "RAW",
@ -372,12 +374,6 @@
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.", "settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
"settingsSectionViewer": "Просмотрщик", "settingsSectionViewer": "Просмотрщик",
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
"settingsViewerShowMinimap": "Показать миникарту",
"settingsViewerShowInformation": "Показывать информацию",
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
"settingsViewerShowShootingDetails": "Показать детали съёмки",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsViewerUseCutout": "Использовать область выреза", "settingsViewerUseCutout": "Использовать область выреза",
"settingsImageBackground": "Фон изображения", "settingsImageBackground": "Фон изображения",
@ -388,6 +384,15 @@
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки", "settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
"settingsViewerQuickActionEmpty": "Нет кнопок", "settingsViewerQuickActionEmpty": "Нет кнопок",
"settingsViewerOverlayTile": "Наложение",
"settingsViewerOverlayTitle": "Наложение",
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
"settingsViewerShowMinimap": "Показать миникарту",
"settingsViewerShowInformation": "Показывать информацию",
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
"settingsViewerShowShootingDetails": "Показать детали съёмки",
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
"settingsVideoPageTitle": "Настройки видео", "settingsVideoPageTitle": "Настройки видео",
"settingsSectionVideo": "Видео", "settingsSectionVideo": "Видео",
"settingsVideoShowVideos": "Показывать видео", "settingsVideoShowVideos": "Показывать видео",
@ -419,15 +424,15 @@
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов", "settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
"settingsSaveSearchHistory": "Сохранять историю поиска", "settingsSaveSearchHistory": "Сохранять историю поиска",
"settingsHiddenFiltersTile": "Скрытые фильтры", "settingsHiddenItemsTile": "Скрытые объекты",
"settingsHiddenItemsTitle": "Скрытые объекты",
"settingsHiddenFiltersTitle": "Скрытые фильтры", "settingsHiddenFiltersTitle": "Скрытые фильтры",
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.", "settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров", "settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
"settingsHiddenPathsTile": "Скрытые каталоги",
"settingsHiddenPathsTitle": "Скрытые каталоги", "settingsHiddenPathsTitle": "Скрытые каталоги",
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.", "settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
"settingsHiddenPathsEmpty": "Нет скрытых каталогов",
"addPathTooltip": "Добавить каталог", "addPathTooltip": "Добавить каталог",
"settingsStorageAccessTile": "Доступ к хранилищу", "settingsStorageAccessTile": "Доступ к хранилищу",
@ -496,8 +501,16 @@
"viewerInfoSearchSuggestionResolution": "Разрешение", "viewerInfoSearchSuggestionResolution": "Разрешение",
"viewerInfoSearchSuggestionRights": "Права", "viewerInfoSearchSuggestionRights": "Права",
"tagEditorSectionRecent": "Недавние",
"panoramaEnableSensorControl": "Включить сенсорное управление", "panoramaEnableSensorControl": "Включить сенсорное управление",
"panoramaDisableSensorControl": "Отключить сенсорное управление", "panoramaDisableSensorControl": "Отключить сенсорное управление",
"sourceViewerPageTitle": "Источник" "sourceViewerPageTitle": "Источник",
"filePickerShowHiddenFiles": "Показывать скрытые файлы",
"filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы",
"filePickerOpenFrom": "Открыть",
"filePickerNoItems": "Ничего нет.",
"filePickerUseThisFolder": "Использовать эту папку"
} }

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
enum EntryInfoAction { enum EntryInfoAction {
// general // general
editDate, editDate,
editTags,
removeMetadata, removeMetadata,
// motion photo // motion photo
viewMotionPhotoVideo, viewMotionPhotoVideo,
@ -13,6 +14,7 @@ enum EntryInfoAction {
class EntryInfoActions { class EntryInfoActions {
static const all = [ static const all = [
EntryInfoAction.editDate, EntryInfoAction.editDate,
EntryInfoAction.editTags,
EntryInfoAction.removeMetadata, EntryInfoAction.removeMetadata,
EntryInfoAction.viewMotionPhotoVideo, EntryInfoAction.viewMotionPhotoVideo,
]; ];
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return context.l10n.entryInfoActionEditDate; return context.l10n.entryInfoActionEditDate;
case EntryInfoAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata; return context.l10n.entryInfoActionRemoveMetadata;
// motion photo // motion photo
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// general // general
case EntryInfoAction.editDate: case EntryInfoAction.editDate:
return AIcons.date; return AIcons.date;
case EntryInfoAction.editTags:
return AIcons.addTag;
case EntryInfoAction.removeMetadata: case EntryInfoAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
// motion photo // motion photo

View file

@ -26,6 +26,7 @@ enum EntrySetAction {
rotateCW, rotateCW,
flip, flip,
editDate, editDate,
editTags,
removeMetadata, removeMetadata,
} }
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionFlip; return context.l10n.entryActionFlip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return context.l10n.entryInfoActionEditDate; return context.l10n.entryInfoActionEditDate;
case EntrySetAction.editTags:
return context.l10n.entryInfoActionEditTags;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return context.l10n.entryInfoActionRemoveMetadata; return context.l10n.entryInfoActionRemoveMetadata;
} }
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.flip; return AIcons.flip;
case EntrySetAction.editDate: case EntrySetAction.editDate:
return AIcons.date; return AIcons.date;
case EntrySetAction.editTags:
return AIcons.addTag;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return AIcons.clear; return AIcons.clear;
} }

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
@immutable
class ActionEvent<T> {
final T action;
const ActionEvent(this.action);
}
@immutable
class ActionStartedEvent<T> extends ActionEvent<T> {
const ActionStartedEvent(T action) : super(action);
}
@immutable
class ActionEndedEvent<T> extends ActionEvent<T> {
const ActionEndedEvent(T action) : super(action);
}

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
@ -17,6 +18,8 @@ abstract class AvesAvailability {
Future<bool> get canLocatePlaces; Future<bool> get canLocatePlaces;
Future<bool> get canUseGoogleMaps;
Future<bool> get isNewVersionAvailable; Future<bool> get isNewVersionAvailable;
} }
@ -59,6 +62,9 @@ class LiveAvesAvailability implements AvesAvailability {
@override @override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices;
@override @override
Future<bool> get isNewVersionAvailable async { Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!); if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);

43
lib/model/device.dart Normal file
View file

@ -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<void> 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;
}
}

View file

@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
enum EntryDataType { basic, catalog, address, references }
class AvesEntry { class AvesEntry {
String uri; String uri;
String? _path, _directory, _filename, _extension; String? _path, _directory, _filename, _extension;
@ -235,6 +237,10 @@ class AvesEntry {
bool get canEdit => path != null; bool get canEdit => path != null;
bool get canEditDate => canEdit && canEditExif;
bool get canEditTags => canEdit && canEditXmp;
bool get canRotateAndFlip => canEdit && canEditExif; bool get canRotateAndFlip => canEdit && canEditExif;
// as of androidx.exifinterface:exifinterface:1.3.3 // 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 // as of latest PixyMeta
bool get canRemoveMetadata { bool get canRemoveMetadata {
switch (mimeType.toLowerCase()) { switch (mimeType.toLowerCase()) {
@ -394,11 +424,11 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
List<String>? _xmpSubjects; Set<String>? _tags;
List<String> get xmpSubjects { Set<String> get tags {
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? []; _tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
return _xmpSubjects!; return _tags!;
} }
String? _bestTitle; String? _bestTitle;
@ -408,13 +438,15 @@ class AvesEntry {
return _bestTitle; return _bestTitle;
} }
CatalogMetadata? get catalogMetadata => _catalogMetadata; int? get catalogDateMillis => _catalogDateMillis;
set catalogDateMillis(int? dateMillis) { set catalogDateMillis(int? dateMillis) {
_catalogDateMillis = dateMillis; _catalogDateMillis = dateMillis;
_bestDate = null; _bestDate = null;
} }
CatalogMetadata? get catalogMetadata => _catalogMetadata;
set catalogMetadata(CatalogMetadata? newMetadata) { set catalogMetadata(CatalogMetadata? newMetadata) {
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees; final oldRotationDegrees = rotationDegrees;
@ -423,8 +455,8 @@ class AvesEntry {
catalogDateMillis = newMetadata?.dateMillis; catalogDateMillis = newMetadata?.dateMillis;
_catalogMetadata = newMetadata; _catalogMetadata = newMetadata;
_bestTitle = null; _bestTitle = null;
_xmpSubjects = null; _tags = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notify();
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
} }
@ -434,7 +466,7 @@ class AvesEntry {
addressDetails = null; addressDetails = null;
} }
Future<void> catalog({required bool background, required bool persist, required bool force}) async { Future<void> catalog({required bool background, required bool force, required bool persist}) async {
if (isCatalogued && !force) return; if (isCatalogued && !force) return;
if (isSvg) { if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading // 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) { set addressDetails(AddressDetails? newAddress) {
_addressDetails = newAddress; _addressDetails = newAddress;
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notify();
} }
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async { Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
@ -590,61 +622,83 @@ class AvesEntry {
} }
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notify();
} }
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async { Future<void> refresh({
_catalogMetadata = null; required bool background,
_addressDetails = null; required bool persist,
required Set<EntryDataType> dataTypes,
required Locale geocoderLocale,
}) async {
// clear derived fields
_bestDate = null; _bestDate = null;
_bestTitle = null; _bestTitle = null;
_xmpSubjects = null; _tags = null;
if (persist) { if (persist) {
await metadataDb.removeIds({contentId!}, metadataOnly: true); await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
} }
final updated = await mediaFileService.getEntry(uri, mimeType); final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) { if (updatedEntry != null) {
await _applyNewFields(updated.toMap(), persist: persist); await _applyNewFields(updatedEntry.toMap(), persist: persist);
await catalog(background: background, persist: persist, force: force);
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
} }
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
} }
Future<bool> rotate({required bool clockwise, required bool persist}) async { Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise); final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
return true; return {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> flip({required bool persist}) async { Future<Set<EntryDataType>> flip({required bool persist}) async {
final newFields = await metadataEditService.flip(this); final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return {};
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
return true; return {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> editDate(DateModifier modifier) async { Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
if (modifier.action == DateEditAction.extractFromTitle) { if (modifier.action == DateEditAction.extractFromTitle) {
final _title = bestTitle; final _title = bestTitle;
if (_title == null) return false; if (_title == null) return {};
final date = parseUnknownDateFormat(_title); final date = parseUnknownDateFormat(_title);
if (date == null) { if (date == null) {
await reportService.recordError('failed to parse date from title=$_title', null); await reportService.recordError('failed to parse date from title=$_title', null);
return false; return {};
} }
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date); modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
} }
final newFields = await metadataEditService.editDate(this, modifier); final newFields = await metadataEditService.editDate(this, modifier);
return newFields.isNotEmpty; return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
};
} }
Future<bool> removeMetadata(Set<MetadataType> types) async { Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
final newFields = await metadataEditService.removeTypes(this, types); final newFields = await metadataEditService.removeTypes(this, types);
return newFields.isNotEmpty; return newFields.isEmpty
? {}
: {
EntryDataType.basic,
EntryDataType.catalog,
EntryDataType.address,
};
} }
Future<bool> delete() { Future<bool> delete() {
@ -665,7 +719,7 @@ class AvesEntry {
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners(); imageChangeNotifier.notify();
} }
} }

View file

@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry { extension ExtraAvesEntryImages on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) { ThumbnailProvider getThumbnail({double extent = 0}) {

View file

@ -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<Set<EntryDataType>> editTags(Set<String> 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<void> _setIptcKeywords(List<Map<String, dynamic>> iptc, Set<String> 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<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
void _setStringBag(XmlNode node, String name, Set<String> 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<Object?> get props => [xmpString, extendedXmpString];
const AvesXmp({
required this.xmpString,
this.extendedXmpString,
});
static AvesXmp? fromList(List<String> xmpStrings) {
switch (xmpStrings.length) {
case 0:
return null;
case 1:
return AvesXmp(xmpString: xmpStrings.single);
default:
final byExtending = groupBy<String, bool>(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);
}
}
}

View file

@ -66,7 +66,9 @@ class AlbumFilter extends CollectionFilter {
return PaletteGenerator.fromImageProvider( return PaletteGenerator.fromImageProvider(
AppIconImage(packageName: packageName, size: 24), AppIconImage(packageName: packageName, size: 24),
).then((palette) async { ).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; _appColors[album] = color;
return color; return color;
}); });

View file

@ -31,6 +31,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
]; ];
static CollectionFilter? fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
if (jsonString.isEmpty) return null;
try { try {
final jsonMap = jsonDecode(jsonString); final jsonMap = jsonDecode(jsonString);
if (jsonMap is Map<String, dynamic>) { if (jsonMap is Map<String, dynamic>) {

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -58,15 +59,17 @@ class LocationFilter extends CollectionFilter {
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
final flag = countryCodeToFlag(_countryCode); if (_countryCode != null && device.canRenderFlagEmojis) {
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates, final flag = countryCodeToFlag(_countryCode);
// not filled with the shadow color as expected, so we remove them // as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
if (flag != null) { // not filled with the shadow color as expected, so we remove them
return Text( if (flag != null) {
flag, return Text(
style: TextStyle(fontSize: size, shadows: const []), flag,
textScaleFactor: 1.0, style: TextStyle(fontSize: size, shadows: const []),
); textScaleFactor: 1.0,
);
}
} }
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size); return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
} }

View file

@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
TagFilter(this.tag) { TagFilter(this.tag) {
if (tag.isEmpty) { if (tag.isEmpty) {
_test = (entry) => entry.xmpSubjects.isEmpty; _test = (entry) => entry.tags.isEmpty;
} else { } else {
_test = (entry) => entry.xmpSubjects.contains(tag); _test = (entry) => entry.tags.contains(tag);
} }
} }

View file

@ -13,6 +13,8 @@ enum DateEditAction {
} }
enum MetadataType { enum MetadataType {
// JPEG COM marker or GIF comment
comment,
// Exif: https://en.wikipedia.org/wiki/Exif // Exif: https://en.wikipedia.org/wiki/Exif
exif, exif,
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile // ICC profile: https://en.wikipedia.org/wiki/ICC_profile
@ -23,8 +25,6 @@ enum MetadataType {
jfif, jfif,
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe // JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
jpegAdobe, jpegAdobe,
// JPEG COM marker
jpegComment,
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky // JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
jpegDucky, jpegDucky,
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/ // Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
@ -42,6 +42,7 @@ class MetadataTypes {
static const common = { static const common = {
MetadataType.exif, MetadataType.exif,
MetadataType.xmp, MetadataType.xmp,
MetadataType.comment,
MetadataType.iccProfile, MetadataType.iccProfile,
MetadataType.iptc, MetadataType.iptc,
MetadataType.photoshopIrb, MetadataType.photoshopIrb,
@ -50,7 +51,6 @@ class MetadataTypes {
static const jpeg = { static const jpeg = {
MetadataType.jfif, MetadataType.jfif,
MetadataType.jpegAdobe, MetadataType.jpegAdobe,
MetadataType.jpegComment,
MetadataType.jpegDucky, MetadataType.jpegDucky,
}; };
} }
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
// match `ExifInterface` directory names // match `ExifInterface` directory names
String getText() { String getText() {
switch (this) { switch (this) {
case MetadataType.comment:
return 'Comment';
case MetadataType.exif: case MetadataType.exif:
return 'Exif'; return 'Exif';
case MetadataType.iccProfile: case MetadataType.iccProfile:
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
return 'JFIF'; return 'JFIF';
case MetadataType.jpegAdobe: case MetadataType.jpegAdobe:
return 'Adobe JPEG'; return 'Adobe JPEG';
case MetadataType.jpegComment:
return 'JpegComment';
case MetadataType.jpegDucky: case MetadataType.jpegDucky:
return 'Ducky'; return 'Ducky';
case MetadataType.photoshopIrb: case MetadataType.photoshopIrb:

View file

@ -1,20 +1,23 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
class OverlayMetadata { @immutable
final String? aperture, exposureTime, focalLength, iso; class OverlayMetadata extends Equatable {
final double? aperture, focalLength;
final String? exposureTime;
final int? iso;
static final apertureFormat = NumberFormat('0.0', 'en_US'); @override
static final focalLengthFormat = NumberFormat('0.#', 'en_US'); List<Object?> get props => [aperture, exposureTime, focalLength, iso];
OverlayMetadata({ bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
double? aperture,
const OverlayMetadata({
this.aperture,
this.exposureTime, this.exposureTime,
double? focalLength, this.focalLength,
int? iso, this.iso,
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null, });
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
iso = iso != null ? 'ISO$iso' : null;
factory OverlayMetadata.fromMap(Map map) { factory OverlayMetadata.fromMap(Map map) {
return OverlayMetadata( return OverlayMetadata(
@ -24,9 +27,4 @@ class OverlayMetadata {
iso: map['iso'] as int?, 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}';
} }

View file

@ -20,7 +20,7 @@ abstract class MetadataDb {
Future<void> reset(); Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}); Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
// entries // entries
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
} }
@override @override
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async { Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return; if (contentIds.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
final db = await _database; final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch(); final batch = db.batch();
const where = 'contentId = ?'; const where = 'contentId = ?';
contentIds.forEach((id) { contentIds.forEach((id) {
final whereArgs = [id]; final whereArgs = [id];
batch.delete(entryTable, where: where, whereArgs: whereArgs); if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(entryTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs); }
batch.delete(addressTable, where: where, whereArgs: whereArgs); if (_dataTypes.contains(EntryDataType.catalog)) {
if (!metadataOnly) { 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(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs); batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);

View file

@ -4,6 +4,13 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class Query extends ChangeNotifier { class Query extends ChangeNotifier {
Query({required String? initialValue}) {
if (initialValue != null && initialValue.isNotEmpty) {
_enabled = true;
queryNotifier.value = initialValue;
}
}
bool _enabled = false; bool _enabled = false;
bool get enabled => _enabled; bool get enabled => _enabled;

View file

@ -2,6 +2,7 @@ import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'enums.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}) { String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
switch (this) { switch (this) {
case CoordinateFormat.dms: 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: 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'] // returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) { static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
final locale = l10n.localeName;
final lat = latLng.latitude; final lat = latLng.latitude;
final lng = latLng.longitude; final lng = latLng.longitude;
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals); final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals, locale);
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals); final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals, locale);
return [ return [
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth), l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast), l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
]; ];
} }
static List<String> _toDecimal(AppLocalizations l10n, LatLng latLng) {
final locale = l10n.localeName;
final formatter = NumberFormat('0.000000°', locale);
return [
formatter.format(latLng.latitude),
formatter.format(latLng.longitude),
];
}
} }

View file

@ -65,6 +65,7 @@ class SettingsDefaults {
static const showOverlayShootingDetails = false; static const showOverlayShootingDetails = false;
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
static const viewerUseCutout = true; static const viewerUseCutout = true;
static const viewerMaxBrightness = false;
// video // video
static const videoQuickActions = [ static const videoQuickActions = [
@ -98,4 +99,7 @@ class SettingsDefaults {
// accessibility // accessibility
static const accessibilityAnimations = AccessibilityAnimations.system; static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
// file picker
static const filePickerShowHiddenFiles = false;
} }

View file

@ -81,6 +81,7 @@ class Settings extends ChangeNotifier {
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect'; static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
static const viewerUseCutoutKey = 'viewer_use_cutout'; static const viewerUseCutoutKey = 'viewer_use_cutout';
static const viewerMaxBrightnessKey = 'viewer_max_brightness';
// video // video
static const videoQuickActionsKey = 'video_quick_actions'; static const videoQuickActionsKey = 'video_quick_actions';
@ -116,6 +117,9 @@ class Settings extends ChangeNotifier {
// version // version
static const lastVersionCheckDateKey = 'last_version_check_date'; static const lastVersionCheckDateKey = 'last_version_check_date';
// file picker
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
// platform settings // platform settings
// cf Android `Settings.System.ACCELEROMETER_ROTATION` // cf Android `Settings.System.ACCELEROMETER_ROTATION`
static const platformAccelerometerRotationKey = 'accelerometer_rotation'; static const platformAccelerometerRotationKey = 'accelerometer_rotation';
@ -152,8 +156,8 @@ class Settings extends ChangeNotifier {
enableOverlayBlurEffect = performanceClass >= 29; enableOverlayBlurEffect = performanceClass >= 29;
// availability // availability
final hasPlayServices = await availability.hasPlayServices; final canUseGoogleMaps = await availability.canUseGoogleMaps;
if (hasPlayServices) { if (canUseGoogleMaps) {
infoMapStyle = EntryMapStyle.googleNormal; infoMapStyle = EntryMapStyle.googleNormal;
} else { } else {
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList(); final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
@ -352,6 +356,10 @@ class Settings extends ChangeNotifier {
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue); set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
bool get viewerMaxBrightness => getBoolOrDefault(viewerMaxBrightnessKey, SettingsDefaults.viewerMaxBrightness);
set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue);
// video // video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values); List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
@ -446,6 +454,12 @@ class Settings extends ChangeNotifier {
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch); 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 // convenience methods
// ignore: avoid_positional_boolean_parameters // ignore: avoid_positional_boolean_parameters
@ -587,10 +601,12 @@ class Settings extends ChangeNotifier {
case showOverlayShootingDetailsKey: case showOverlayShootingDetailsKey:
case enableOverlayBlurEffectKey: case enableOverlayBlurEffectKey:
case viewerUseCutoutKey: case viewerUseCutoutKey:
case viewerMaxBrightnessKey:
case enableVideoHardwareAccelerationKey: case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey: case enableVideoAutoPlayKey:
case subtitleShowOutlineKey: case subtitleShowOutlineKey:
case saveSearchHistoryKey: case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey:
if (value is bool) { if (value is bool) {
_prefs!.setBool(key, value); _prefs!.setBool(key, value);
} else { } else {

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/album.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/filters/query.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.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/location.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
@ -49,7 +51,12 @@ class CollectionLens with ChangeNotifier {
final sourceEvents = source.eventBus; final sourceEvents = source.eventBus;
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries))); _subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries))); _subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
if (e.type == MoveType.move) {
// refreshing copied items is already handled via `EntryAddedEvent`s
_refresh();
}
}));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.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/album.dart';
import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums.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/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/analysis_service.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)); _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); _entryById.addAll(newIdMapEntries);
_rawEntries.addAll(entries); _rawEntries.addAll(entries);
@ -183,8 +187,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return; return;
} }
await _moveEntry(entry, newFields, persist: persist); await _moveEntry(entry, newFields, persist: persist);
entry.metadataChangeNotifier.notifyListeners(); entry.metadataChangeNotifier.notify();
eventBus.fire(EntryMovedEvent({entry})); eventBus.fire(EntryMovedEvent(MoveType.move, {entry}));
completer.complete(true); completer.complete(true);
}, },
); );
@ -245,6 +249,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
title: newFields['title'] as String?, title: newFields['title'] as String?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
)); ));
} else {
debugPrint('failed to find source entry with uri=$sourceUri');
} }
}); });
await metadataDb.saveEntries(movedEntries); await metadataDb.saveEntries(movedEntries);
@ -273,7 +279,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
invalidateAlbumFilterSummary(directories: fromAlbums); invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(movedEntries); _invalidate(movedEntries);
eventBus.fire(EntryMovedEvent(movedEntries)); eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
} }
bool get initialized => false; bool get initialized => false;
@ -284,8 +290,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}); Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> refreshEntry(AvesEntry entry) async { Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale); await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
updateDerivedFilters({entry}); updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry})); eventBus.fire(EntryRefreshedEvent({entry}));
} }
@ -381,46 +387,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
} }
} }
@immutable
class EntryAddedEvent {
final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> 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});
}

View file

@ -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<AvesEntry>? entries;
const EntryAddedEvent([this.entries]);
}
@immutable
class EntryRemovedEvent {
final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries);
}
@immutable
class EntryMovedEvent {
final MoveType type;
final Set<AvesEntry> entries;
const EntryMovedEvent(this.type, this.entries);
}
@immutable
class EntryRefreshedEvent {
final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries);
}
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> 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});
}

View file

@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries // clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove 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` // verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');

View file

@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
var stopCheckCount = 0; var stopCheckCount = 0;
final newMetadata = <CatalogMetadata>{}; final newMetadata = <CatalogMetadata>{};
for (final entry in todo) { 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) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!); newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= commitCountThreshold) { if (newMetadata.length >= commitCountThreshold) {
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
} }
void updateTags() { 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)) { if (!listEquals(updatedTags, sortedTags)) {
sortedTags = List.unmodifiable(updatedTags); sortedTags = List.unmodifiable(updatedTags);
invalidateTagFilterSummary(); invalidateTagFilterSummary();
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
_filterEntryCountMap.clear(); _filterEntryCountMap.clear();
_filterRecentEntryMap.clear(); _filterRecentEntryMap.clear();
} else { } 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); tags.forEach(_filterEntryCountMap.remove);
} }
eventBus.fire(TagSummaryInvalidatedEvent(tags)); eventBus.fire(TagSummaryInvalidatedEvent(tags));

View file

@ -81,7 +81,9 @@ class VideoMetadataFormatter {
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async { static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
final mediaInfo = await getVideoMetadata(entry); 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]; var dateString = mediaInfo[Keys.date];
if (!isDefined(dateString)) { if (!isDefined(dateString)) {
@ -112,6 +114,7 @@ class VideoMetadataFormatter {
// `DateTime` does not recognize: // `DateTime` does not recognize:
// - `UTC 2021-05-30 19:14:21` // - `UTC 2021-05-30 19:14:21`
// - `2021`
final match = _anotherDatePattern.firstMatch(dateString); final match = _anotherDatePattern.firstMatch(dateString);
if (match != null) { if (match != null) {
@ -371,7 +374,7 @@ class VideoMetadataFormatter {
static String _formatFilesize(String value) { static String _formatFilesize(String value) {
final size = int.tryParse(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) { static String _formatLanguage(String value) {
@ -396,20 +399,10 @@ class VideoMetadataFormatter {
if (parsed == null) return size; if (parsed == null) return size;
size = parsed; size = parsed;
} }
const divider = 1000; const divider = 1000;
if (size < divider) return '$size $unit'; if (size < divider) return '$size $unit';
if (size < divider * divider) return '${(size / divider).toStringAsFixed(round)} K$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';
}
return '${(size / divider / divider).toStringAsFixed(round)} M$unit'; return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
} }
} }

6
lib/ref/iptc.dart Normal file
View file

@ -0,0 +1,6 @@
class IPTC {
static const int applicationRecord = 2;
// ApplicationRecord tags
static const int keywordsTag = 25;
}

View file

@ -8,8 +8,8 @@ import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/model/source/source_state.dart'; import 'package:aves/model/source/source_state.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:fijkplayer/fijkplayer.dart'; import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AnalysisService { class AnalysisService {

View file

@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -29,8 +28,6 @@ abstract class AndroidAppService {
Future<bool> shareSingle(String uri, String mimeType); Future<bool> shareSingle(String uri, String mimeType);
Future<bool> canPinToHomeScreen();
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}); Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
} }
@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService {
// app shortcuts // app shortcuts
// this ability will not change over the lifetime of the app
bool? _canPin;
@override
Future<bool> 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 @override
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async { Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
Uint8List? iconBytes; Uint8List? iconBytes;
@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService {
); );
} }
try { try {
await platform.invokeMethod('pin', <String, dynamic>{ await platform.invokeMethod('pinShortcut', <String, dynamic>{
'label': label, 'label': label,
'iconBytes': iconBytes, 'iconBytes': iconBytes,
'filters': filters?.map((filter) => filter.toJson()).toList(), 'filters': filters?.map((filter) => filter.toJson()).toList(),

View file

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
// cf flutter/foundation `consolidateHttpClientResponseBytes` // adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart`
class OutputBuffer extends ByteConversionSinkBase { class OutputBuffer extends ByteConversionSinkBase {
List<List<int>>? _chunks = <List<int>>[]; List<List<int>>? _chunks = <List<int>>[];
int _contentLength = 0; int _contentLength = 0;
@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase {
return; return;
} }
_bytes = Uint8List(_contentLength); _bytes = Uint8List(_contentLength);
var offset = 0; int offset = 0;
for (final chunk in _chunks!) { for (final List<int> chunk in _chunks!) {
_bytes!.setRange(offset, offset + chunk.length, chunk); _bytes!.setRange(offset, offset + chunk.length, chunk);
offset += chunk.length; offset += chunk.length;
} }

View file

@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class DeviceService { abstract class DeviceService {
Future<Map<String, dynamic>> getCapabilities();
Future<String?> getDefaultTimeZone(); Future<String?> getDefaultTimeZone();
Future<int> getPerformanceClass(); Future<int> getPerformanceClass();
@ -10,6 +12,17 @@ abstract class DeviceService {
class PlatformDeviceService implements DeviceService { class PlatformDeviceService implements DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device'); static const platform = MethodChannel('deckers.thibault/aves/device');
@override
Future<Map<String, dynamic>> getCapabilities() async {
try {
final result = await platform.invokeMethod('getCapabilities');
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override @override
Future<String?> getDefaultTimeZone() async { Future<String?> getDefaultTimeZone() async {
try { try {

View file

@ -1,11 +1,13 @@
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
// names should match possible values on platform // names should match possible values on platform
enum NameConflictStrategy { rename, replace, skip } enum NameConflictStrategy { rename, replace, skip }
extension ExtraNameConflictStrategy on NameConflictStrategy { 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) { String getName(BuildContext context) {
switch (this) { switch (this) {

View file

@ -159,7 +159,7 @@ class PlatformMediaFileService implements MediaFileService {
int? pageId, int? pageId,
int? expectedContentLength, int? expectedContentLength,
BytesReceivedCallback? onBytesReceived, BytesReceivedCallback? onBytesReceived,
}) { }) async {
try { try {
final completer = Completer<Uint8List>.sync(); final completer = Completer<Uint8List>.sync();
final sink = OutputBuffer(); final sink = OutputBuffer();
@ -191,11 +191,12 @@ class PlatformMediaFileService implements MediaFileService {
}, },
cancelOnError: true, cancelOnError: true,
); );
return completer.future; // `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
return Future.sync(() => Uint8List(0)); return Uint8List(0);
} }
@override @override

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; 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/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -13,6 +14,10 @@ abstract class MetadataEditService {
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier); Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types); Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
} }
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
return {}; return {};
} }
@override
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
try {
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'iptc': iptc,
'postEditScan': postEditScan,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
try {
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'xmp': xmp?.xmpString,
'extendedXmp': xmp?.extendedXmpString,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return {};
}
@override @override
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async { Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
try { try {
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
String _toPlatformMetadataType(MetadataType type) { String _toPlatformMetadataType(MetadataType type) {
switch (type) { switch (type) {
case MetadataType.comment:
return 'comment';
case MetadataType.exif: case MetadataType.exif:
return 'exif'; return 'exif';
case MetadataType.iccProfile: case MetadataType.iccProfile:
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
return 'jfif'; return 'jfif';
case MetadataType.jpegAdobe: case MetadataType.jpegAdobe:
return 'jpeg_adobe'; return 'jpeg_adobe';
case MetadataType.jpegComment:
return 'jpeg_comment';
case MetadataType.jpegDucky: case MetadataType.jpegDucky:
return 'jpeg_ducky'; return 'jpeg_ducky';
case MetadataType.photoshopIrb: case MetadataType.photoshopIrb:

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; 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/catalog.dart';
import 'package:aves/model/metadata/overlay.dart'; import 'package:aves/model/metadata/overlay.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry); Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
Future<AvesXmp?> getXmp(AvesEntry entry);
Future<bool> hasContentResolverProp(String prop); Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop); Future<String?> getContentResolverProp(AvesEntry entry, String prop);
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
return null; return null;
} }
@override
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getIptc', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
});
if (result != null) return (result as List).cast<Map>().map((fields) => fields.cast<String, dynamic>()).toList();
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
@override
Future<AvesXmp?> getXmp(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getXmp', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'sizeBytes': entry.sizeBytes,
});
if (result != null) return AvesXmp.fromList((result as List).cast<String>());
} on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack);
}
}
return null;
}
final Map<String, bool> _contentResolverProps = {}; final Map<String, bool> _contentResolverProps = {};
@override @override

View file

@ -37,8 +37,6 @@ abstract class StorageService {
Future<bool?> createFile(String name, String mimeType, Uint8List bytes); Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType); Future<Uint8List> openFile(String mimeType);
Future<String?> selectDirectory();
} }
class PlatformStorageService implements StorageService { class PlatformStorageService implements StorageService {
@ -174,7 +172,8 @@ class PlatformStorageService implements StorageService {
}, },
cancelOnError: true, cancelOnError: true,
); );
return completer.future; // `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
@ -198,7 +197,8 @@ class PlatformStorageService implements StorageService {
}, },
cancelOnError: true, cancelOnError: true,
); );
return completer.future; // `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
@ -222,7 +222,8 @@ class PlatformStorageService implements StorageService {
}, },
cancelOnError: true, cancelOnError: true,
); );
return completer.future; // `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
@ -249,31 +250,11 @@ class PlatformStorageService implements StorageService {
}, },
cancelOnError: true, cancelOnError: true,
); );
return completer.future; // `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
return Uint8List(0); return Uint8List(0);
} }
@override
Future<String?> selectDirectory() async {
try {
final completer = Completer<String?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'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;
}
} }

View file

@ -46,6 +46,7 @@ class Durations {
// info animations // info animations
static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
static const xmpStructArrayCardTransition = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
static const tagEditorTransition = Duration(milliseconds: 200);
// settings animations // settings animations
static const quickActionListAnimation = Duration(milliseconds: 200); static const quickActionListAnimation = Duration(milliseconds: 200);

View file

@ -14,11 +14,13 @@ class AIcons {
static const IconData date = Icons.calendar_today_outlined; static const IconData date = Icons.calendar_today_outlined;
static const IconData disc = Icons.fiber_manual_record; static const IconData disc = Icons.fiber_manual_record;
static const IconData error = Icons.error_outline; 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 grid = Icons.grid_on_outlined;
static const IconData home = Icons.home_outlined; static const IconData home = Icons.home_outlined;
static const IconData language = Icons.translate_outlined; static const IconData language = Icons.translate_outlined;
static const IconData location = Icons.place_outlined; static const IconData location = Icons.place_outlined;
static const IconData locationOff = Icons.location_off_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 privacy = MdiIcons.shieldAccountOutline;
static const IconData raw = Icons.raw_on_outlined; static const IconData raw = Icons.raw_on_outlined;
static const IconData shooting = Icons.camera_outlined; static const IconData shooting = Icons.camera_outlined;
@ -33,6 +35,7 @@ class AIcons {
// actions // actions
static const IconData add = Icons.add_circle_outline; static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined; 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 replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined; static const IconData captureFrame = Icons.screenshot_outlined;
@ -66,6 +69,7 @@ class AIcons {
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;
static const IconData refresh = Icons.refresh_outlined; static const IconData refresh = Icons.refresh_outlined;
static const IconData rename = Icons.title_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 rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_outlined; static const IconData rotateScreen = Icons.screen_rotation_outlined;

View file

@ -32,10 +32,10 @@ class AndroidFileUtils {
downloadPath = pContext.join(primaryStorage, 'Download'); downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies'); moviesPath = pContext.join(primaryStorage, 'Movies');
picturesPath = pContext.join(primaryStorage, 'Pictures'); picturesPath = pContext.join(primaryStorage, 'Pictures');
avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures'); avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures');
videoCapturesPaths = { videoCapturesPaths = {
// from Samsung // from Samsung
pContext.join(dcimPath, 'Video Captures'), pContext.join(dcimPath, 'Videocaptures'),
// from Aves // from Aves
avesVideoCapturesPath, avesVideoCapturesPath,
}; };

View file

@ -1,26 +1,9 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin // `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin
class AChangeNotifier implements Listenable { class AChangeNotifier extends ChangeNotifier {
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>(); void notify() {
// why is this protected?
@override super.notifyListeners();
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<VoidCallback>.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');
}
}
} }
} }

View file

@ -132,6 +132,11 @@ class Constants {
license: 'Apache 2.0', license: 'Apache 2.0',
sourceUrl: 'https://github.com/DavBfr/dart_pdf', sourceUrl: 'https://github.com/DavBfr/dart_pdf',
), ),
Dependency(
name: 'Screen Brightness',
license: 'MIT',
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
),
Dependency( Dependency(
name: 'Shared Preferences', name: 'Shared Preferences',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',

View file

@ -1,38 +1,16 @@
String formatFilesize(int size, {int round = 2}) { import 'package:intl/intl.dart';
var divider = 1024;
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) { String formatFileSize(String locale, int size, {int round = 2}) {
return '${(size / divider).toStringAsFixed(0)} KB'; if (size < _kiloDivider) return '$size B';
}
if (size < divider * divider) {
return '${(size / divider).toStringAsFixed(round)} KB';
}
if (size < divider * divider * divider && size % divider == 0) { final formatter = NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale);
return '${(size / (divider * divider)).toStringAsFixed(0)} MB'; if (size < _megaDivider) return '${formatter.format(size / _kiloDivider)} KB';
} if (size < _gigaDivider) return '${formatter.format(size / _megaDivider)} MB';
if (size < divider * divider * divider) { if (size < _teraDivider) return '${formatter.format(size / _gigaDivider)} GB';
return '${(size / divider / divider).toStringAsFixed(round)} MB'; return '${formatter.format(size / _teraDivider)} TB';
}
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';
} }

View file

@ -1,33 +1,23 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class GeoUtils { class GeoUtils {
static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { static String decimal2sexagesimal(
List<int> _split(final double value) { double degDecimal,
// NumberFormat is necessary to create digit after comma if the value bool minuteSecondPadding,
// has no decimal point (only necessary for browser) int secondDecimals,
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.'); String locale,
return <int>[ ) {
int.parse(tmp[0]).abs(), final degAbs = degDecimal.abs();
int.parse(tmp[1]), final deg = degAbs.toInt();
]; final minDecimal = (degAbs - deg) * 60;
} final min = minDecimal.toInt();
final deg = _split(degDecimal)[0];
final minDecimal = (degDecimal.abs() - deg) * 60;
final min = _split(minDecimal)[0];
final sec = (minDecimal - min) * 60; final sec = (minDecimal - min) * 60;
final secRounded = roundToPrecision(sec, decimals: secondDecimals); var minText = NumberFormat('0' * (minuteSecondPadding ? 2 : 1), locale).format(min);
var minText = '$min'; var secText = NumberFormat('${'0' * (minuteSecondPadding ? 2 : 1)}${secondDecimals > 0 ? '.${'0' * secondDecimals}' : ''}', locale).format(sec);
var secText = secRounded.toStringAsFixed(secondDecimals);
if (minuteSecondPadding) {
minText = minText.padLeft(2, '0');
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
}
return '$deg° $minText $secText'; return '$deg° $minText $secText';
} }

View file

@ -8,12 +8,13 @@ import 'package:flutter/material.dart';
class AboutCredits extends StatelessWidget { class AboutCredits extends StatelessWidget {
const AboutCredits({Key? key}) : super(key: key); const AboutCredits({Key? key}) : super(key: key);
static const translations = [ static const translators = {
'Русский: D3ZOXY', 'Русский': 'D3ZOXY',
]; };
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
@ -23,13 +24,13 @@ class AboutCredits extends StatelessWidget {
constraints: const BoxConstraints(minHeight: 48), constraints: const BoxConstraints(minHeight: 48),
child: Align( child: Align(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle), child: Text(l10n.aboutCredits, style: Constants.titleTextStyle),
), ),
), ),
Text.rich( Text.rich(
TextSpan( TextSpan(
children: [ children: [
TextSpan(text: context.l10n.aboutCreditsWorldAtlas1), TextSpan(text: l10n.aboutCreditsWorldAtlas1),
const WidgetSpan( const WidgetSpan(
child: LinkChip( child: LinkChip(
text: 'World Atlas', text: 'World Atlas',
@ -38,17 +39,19 @@ class AboutCredits extends StatelessWidget {
), ),
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
), ),
TextSpan(text: context.l10n.aboutCreditsWorldAtlas2), TextSpan(text: l10n.aboutCreditsWorldAtlas2),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(context.l10n.aboutCreditsTranslators), Text(l10n.aboutCreditsTranslators),
...translations.map( ...translators.entries.map(
(line) => Padding( (kv) {
padding: const EdgeInsetsDirectional.only(start: 8, top: 8), return Padding(
child: Text(line), padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
), child: Text(l10n.aboutCreditsTranslatorLine(kv.key, kv.value)),
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],

View file

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/app_flavor.dart'; import 'package:aves/app_flavor.dart';
import 'package:aves/app_mode.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/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
@ -161,6 +163,7 @@ class _AvesAppState extends State<AvesApp> {
isRotationLocked: await windowService.isRotationLocked(), isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
); );
await device.init();
FijkLog.setLevel(FijkLogLevel.Warn); FijkLog.setLevel(FijkLogLevel.Warn);
// keep screen on // keep screen on

View file

@ -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_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.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/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
@ -46,7 +45,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
final FocusNode _queryBarFocusNode = FocusNode(); final FocusNode _queryBarFocusNode = FocusNode();
late final Listenable _queryFocusRequestNotifier; late final Listenable _queryFocusRequestNotifier;
@ -69,7 +67,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange); _isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
} }
@ -104,53 +101,46 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return FutureBuilder<bool>( return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
future: _canAddShortcutsLoader, selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
builder: (context, snapshot) { builder: (context, s, child) {
final canAddShortcuts = snapshot.data ?? false; final isSelecting = s.item1;
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>( final selectedItemCount = s.item2;
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length), _isSelectingNotifier.value = isSelecting;
builder: (context, s, child) { return AnimatedBuilder(
final isSelecting = s.item1; animation: collection.filterChangeNotifier,
final selectedItemCount = s.item2; builder: (context, child) {
_isSelectingNotifier.value = isSelecting; final removableFilters = appMode != AppMode.pickInternal;
return AnimatedBuilder( return Selector<Query, bool>(
animation: collection.filterChangeNotifier, selector: (context, query) => query.enabled,
builder: (context, child) { builder: (context, queryEnabled, child) {
final removableFilters = appMode != AppMode.pickInternal; return SliverAppBar(
return Selector<Query, bool>( leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
selector: (context, query) => query.enabled, title: _buildAppBarTitle(isSelecting),
builder: (context, queryEnabled, child) { actions: _buildActions(
return SliverAppBar( isSelecting: isSelecting,
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, selectedItemCount: selectedItemCount,
title: _buildAppBarTitle(isSelecting), ),
actions: _buildActions( bottom: PreferredSize(
isSelecting: isSelecting, preferredSize: Size.fromHeight(appBarBottomHeight),
selectedItemCount: selectedItemCount, child: Column(
supportShortcuts: canAddShortcuts, children: [
), if (showFilterBar)
bottom: PreferredSize( FilterBar(
preferredSize: Size.fromHeight(appBarBottomHeight), filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
child: Column( removable: removableFilters,
children: [ onTap: removableFilters ? collection.removeFilter : null,
if (showFilterBar) ),
FilterBar( if (queryEnabled)
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(), EntryQueryBar(
removable: removableFilters, queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
onTap: removableFilters ? collection.removeFilter : null, focusNode: _queryBarFocusNode,
), )
if (queryEnabled) ],
EntryQueryBar( ),
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier), ),
focusNode: _queryBarFocusNode, titleSpacing: 0,
) floating: true,
],
),
),
titleSpacing: 0,
floating: true,
);
},
); );
}, },
); );
@ -214,14 +204,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions({ List<Widget> _buildActions({
required bool isSelecting, required bool isSelecting,
required int selectedItemCount, required int selectedItemCount,
required bool supportShortcuts,
}) { }) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible( bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
action, action,
appMode: appMode, appMode: appMode,
isSelecting: isSelecting, isSelecting: isSelecting,
supportShortcuts: supportShortcuts,
sortFactor: collection.sortFactor, sortFactor: collection.sortFactor,
itemCount: collection.entryCount, itemCount: collection.entryCount,
selectedItemCount: selectedItemCount, selectedItemCount: selectedItemCount,
@ -269,6 +257,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_buildRotateAndFlipMenuItems(context, canApply: canApply), _buildRotateAndFlipMenuItems(context, canApply: canApply),
...[ ...[
EntrySetAction.editDate, EntrySetAction.editDate,
EntrySetAction.editTags,
EntrySetAction.removeMetadata, EntrySetAction.removeMetadata,
].map((action) => _toMenuItem(action, enabled: canApply(action))), ].map((action) => _toMenuItem(action, enabled: canApply(action))),
], ],
@ -295,7 +284,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') // 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}) { Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
final onPressed = enabled ? () => _onActionSelected(action) : null; final onPressed = enabled ? () => _onActionSelected(action) : null;
@ -439,6 +429,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
_actionDelegate.onActionSelected(context, action); _actionDelegate.onActionSelected(context, action);
break; break;

View file

@ -1,4 +1,5 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/collection_grid.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/query_provider.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -37,10 +39,12 @@ class _CollectionPageState extends State<CollectionPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SelectionProvider<AvesEntry>( body: SelectionProvider<AvesEntry>(
child: QueryProvider( child: QueryProvider(
initialQuery: liveFilter?.query,
child: Builder( child: Builder(
builder: (context) => WillPopScope( builder: (context) => WillPopScope(
onWillPop: () { onWillPop: () {

View file

@ -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/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/file_utils.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/draggable_thumb_label.dart';
import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -48,7 +49,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
]; ];
case EntrySortFactor.size: case EntrySortFactor.size:
return [ return [
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0), if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),
]; ];
} }
}, },

View file

@ -4,7 +4,9 @@ import 'dart:io';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.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.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
@ -43,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, { EntrySetAction action, {
required AppMode appMode, required AppMode appMode,
required bool isSelecting, required bool isSelecting,
required bool supportShortcuts,
required EntrySortFactor sortFactor, required EntrySortFactor sortFactor,
required int itemCount, required int itemCount,
required int selectedItemCount, required int selectedItemCount,
@ -66,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
return !isSelecting; return !isSelecting;
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
return appMode == AppMode.main && !isSelecting && supportShortcuts; return appMode == AppMode.main && !isSelecting && device.canPinShortcut;
// browsing or selecting // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
case EntrySetAction.stats: case EntrySetAction.stats:
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return appMode == AppMode.main && isSelecting; return appMode == AppMode.main && isSelecting;
} }
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.rotateCW: case EntrySetAction.rotateCW:
case EntrySetAction.flip: case EntrySetAction.flip:
case EntrySetAction.editDate: case EntrySetAction.editDate:
case EntrySetAction.editTags:
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
return hasSelection; return hasSelection;
} }
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.editDate: case EntrySetAction.editDate:
_editDate(context); _editDate(context);
break; break;
case EntrySetAction.editTags:
_editTags(context);
break;
case EntrySetAction.removeMetadata: case EntrySetAction.removeMetadata:
_removeMetadata(context); _removeMetadata(context);
break; break;
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
BuildContext context, BuildContext context,
Selection<AvesEntry> selection, Selection<AvesEntry> selection,
Set<AvesEntry> todoItems, Set<AvesEntry> todoItems,
Future<bool> Function(AvesEntry entry) op, Future<Set<EntryDataType>> Function(AvesEntry entry) op,
) async { ) async {
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = todoItems.length; final todoCount = todoItems.length;
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
context: context, context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async { opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
final success = await op(entry); final dataTypes = await op(entry);
return ImageOpEvent(success: success, uri: entry.uri); return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
}).asBroadcastStream(), }).asBroadcastStream(),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
); );
if (confirmed == null || !confirmed) return null; 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; return supported;
} }
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); 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; if (todoItems == null || todoItems.isEmpty) return;
final modifier = await selectDateModifier(context, todoItems); 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)); await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
} }
Future<void> _editTags(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>();
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<void> _removeMetadata(BuildContext context) async { Future<void> _removeMetadata(BuildContext context) async {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
@ -596,6 +626,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; 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);
}
} }
} }

View file

@ -65,8 +65,8 @@ class MonthSectionHeader<T> extends StatelessWidget {
if (date == null) return l10n.sectionUnknown; if (date == null) return l10n.sectionUnknown;
if (date.isThisMonth) return l10n.dateThisMonth; if (date.isThisMonth) return l10n.dateThisMonth;
final locale = l10n.localeName; final locale = l10n.localeName;
if (date.isThisYear) return DateFormat.MMMM(locale).format(date); final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date);
return DateFormat.yMMMM(locale).format(date); return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}';
} }
@override @override

View file

@ -42,8 +42,6 @@ class _EntryQueryBarState extends State<EntryQueryBar> {
super.dispose(); 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) { void _registerWidget(EntryQueryBar widget) {
widget.queryNotifier.addListener(_onQueryChanged); widget.queryNotifier.addListener(_onQueryChanged);
} }

View file

@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
import 'package:aves/widgets/dialogs/remove_entry_metadata_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'; import 'package:flutter/material.dart';
mixin EntryEditorMixin { mixin EntryEditorMixin {
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
return modifier; return modifier;
} }
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> 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<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async { Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
if (entries.isEmpty) return null; if (entries.isEmpty) return null;

View file

@ -75,13 +75,15 @@ mixin SizeAwareMixin {
await showDialog( await showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
final neededSize = formatFilesize(needed); final l10n = context.l10n;
final freeSize = formatFilesize(free); final locale = l10n.localeName;
final neededSize = formatFileSize(locale, needed);
final freeSize = formatFileSize(locale, free);
final volume = destinationVolume.getDescription(context); final volume = destinationVolume.getDescription(context);
return AvesDialog( return AvesDialog(
context: context, context: context,
title: context.l10n.notEnoughSpaceDialogTitle, title: l10n.notEnoughSpaceDialogTitle,
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.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/model/source/source_state.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';

View file

@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:highlight/highlight.dart' show highlight, Node; 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 /// Highlight Flutter Widget
class AvesHighlightView extends StatelessWidget { class AvesHighlightView extends StatelessWidget {

View file

@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.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 - removed default thumb builders
- allow any `ScrollView` as child - allow any `ScrollView` as child
- allow any `Widget` as label content - allow any `Widget` as label content

View file

@ -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<PointerDeviceKind>? 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<int, Offset> _pointerLocations;
late List<int> _pointerQueue; // A queue to sort pointers in order of entrance
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
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 = <int, Offset>{};
_pointerQueue = <int>[];
}
}
@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<void>('onEnd', () => onEnd!(ScaleEndDetails(velocity: velocity, pointerCount: _pointerQueue.length)));
} else {
invokeCallback<void>('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<void>('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<void>('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';
}

View file

@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
final String? title; final String? title;
final Iterable<CollectionFilter> filters; final Iterable<CollectionFilter> filters;
final ValueNotifier<String?> expandedNotifier; final ValueNotifier<String?> expandedNotifier;
final bool showGenericIcon;
final HeroType Function(CollectionFilter filter)? heroTypeBuilder; final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const ExpandableFilterRow({ const ExpandableFilterRow({
Key? key, Key? key,
this.title, this.title,
required this.filters, required this.filters,
required this.expandedNotifier, required this.expandedNotifier,
this.showGenericIcon = true,
this.heroTypeBuilder, this.heroTypeBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key); }) : super(key: key);
static const double horizontalPadding = 8; static const double horizontalPadding = 8;
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
// key `album-{path}` is expected by test driver // key `album-{path}` is expected by test driver
key: Key(filter.key), key: Key(filter.key),
filter: filter, filter: filter,
showGenericIcon: showGenericIcon,
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
onTap: onTap, onTap: onTap,
onLongPress: onLongPress,
); );
} }
} }

View file

@ -3,7 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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: // to transition between 2 different fits during hero animation:
// - BoxFit.cover at t=0 // - BoxFit.cover at t=0
// - BoxFit.contain at t=1 // - BoxFit.contain at t=1
@ -190,7 +190,8 @@ class _TransitionImagePainter extends CustomPainter {
Offset.zero & inputSize, Offset.zero & inputSize,
); );
if (background != null) { 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); canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
} }

View file

@ -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 // 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. // because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
// cf https://github.com/flutter/flutter/issues/49027 // cf https://github.com/flutter/flutter/issues/49027
// adapted from `RenderSliverFixedExtentBoxAdaptor` // adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart`
class SectionedListSliver<T> extends StatelessWidget { class SectionedListSliver<T> extends StatelessWidget {
const SectionedListSliver({Key? key}) : super(key: key); const SectionedListSliver({Key? key}) : super(key: key);

View file

@ -18,7 +18,7 @@ import 'package:provider/provider.dart';
typedef FilterCallback = void Function(CollectionFilter filter); typedef FilterCallback = void Function(CollectionFilter filter);
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition); typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
enum HeroType { always, onTap } enum HeroType { always, onTap, never }
@immutable @immutable
class AvesFilterDecoration { class AvesFilterDecoration {
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable, showGenericIcon, useFilterColor; final bool removable, showGenericIcon, useFilterColor;
final AvesFilterDecoration? decoration; final AvesFilterDecoration? decoration;
final String? banner; final String? banner;
final Widget? details; final Widget? leadingOverride, details;
final double padding, maxWidth; final double padding, maxWidth;
final HeroType heroType; final HeroType heroType;
final FilterCallback? onTap; final FilterCallback? onTap;
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
this.useFilterColor = true, this.useFilterColor = true,
this.decoration, this.decoration,
this.banner, this.banner,
this.leadingOverride,
this.details, this.details,
this.padding = 6.0, this.padding = 6.0,
this.maxWidth = defaultMaxChipWidth, this.maxWidth = defaultMaxChipWidth,
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final chipBackground = Theme.of(context).scaffoldBackgroundColor; final chipBackground = Theme.of(context).scaffoldBackgroundColor;
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = AvesFilterChip.iconSize * textScaleFactor; 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 trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
final decoration = widget.decoration; final decoration = widget.decoration;

View file

@ -18,13 +18,14 @@ class MagnifierController {
late ScaleStateChange _currentScaleState, previousScaleState; late ScaleStateChange _currentScaleState, previousScaleState;
MagnifierController({ MagnifierController({
Offset initialPosition = Offset.zero, MagnifierState? initialState,
}) : super() { }) : super() {
initial = MagnifierState( initial = initialState ??
position: initialPosition, const MagnifierState(
scale: null, position: Offset.zero,
source: ChangeSource.internal, scale: null,
); source: ChangeSource.internal,
);
previousState = initial; previousState = initial;
_currentState = initial; _currentState = initial;
_setState(initial); _setState(initial);

Some files were not shown because too many files have changed in this diff Show more