Merge branch 'develop'
This commit is contained in:
commit
0a4c04b2dd
168 changed files with 4226 additions and 1227 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.5.7] - 2021-12-01
|
||||
|
||||
### Added
|
||||
|
||||
- add and remove tags to JPEG/GIF/PNG/TIFF images
|
||||
- French translation
|
||||
- support for Android KitKat (without Google Maps)
|
||||
- Viewer: maximum brightness option
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings: select hidden path directory with a custom file picker instead of the native SAF one
|
||||
- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed
|
||||
|
||||
### Fixed
|
||||
|
||||
- pinch-to-zoom gesture on thumbnails was difficult to trigger
|
||||
- double-tap gesture in the viewer was ignored in some cases
|
||||
- copied items had the wrong date
|
||||
|
||||
## [v1.5.6] - 2021-11-12
|
||||
|
||||
### Added
|
||||
|
|
|
@ -29,7 +29,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
|
|||
|
||||
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||
|
||||
Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
|
||||
Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
@ -55,7 +55,7 @@ At this stage this project does *not* accept PRs, except for translations.
|
|||
|
||||
### Translations
|
||||
|
||||
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled.
|
||||
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled.
|
||||
|
||||
### Donations
|
||||
|
||||
|
@ -82,5 +82,10 @@ To run the app:
|
|||
# flutter run -t lib/main_play.dart --flavor play
|
||||
```
|
||||
|
||||
To run the app on API 19 emulators:
|
||||
```
|
||||
# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering
|
||||
```
|
||||
|
||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||
|
|
|
@ -55,9 +55,8 @@ android {
|
|||
applicationId appId
|
||||
// minSdkVersion constraints:
|
||||
// - Flutter & other plugins: 16
|
||||
// - google_maps_flutter v2.0.5: 20
|
||||
// - Aves native: 19
|
||||
minSdkVersion 20
|
||||
// - google_maps_flutter v2.1.1: 20
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 31
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
@ -149,7 +148,7 @@ dependencies {
|
|||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.3.0'
|
||||
|
|
|
@ -4,21 +4,12 @@
|
|||
android:installLocation="auto">
|
||||
|
||||
<!--
|
||||
Scoped storage for primary storage is unusable on Android Q,
|
||||
because users are required to confirm each file to be edited or deleted.
|
||||
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
|
||||
Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
|
||||
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
|
||||
-->
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29"
|
||||
|
@ -34,6 +25,9 @@
|
|||
<!-- for API < 26 -->
|
||||
<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 -->
|
||||
<queries>
|
||||
<intent>
|
||||
|
|
|
@ -23,7 +23,6 @@ import io.flutter.embedding.engine.FlutterEngine
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.*
|
||||
|
||||
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
||||
private var backgroundFlutterEngine: FlutterEngine? = null
|
||||
|
@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
|
||||
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
|
||||
// channels for analysis
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
|
@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
getString(R.string.analysis_notification_action_stop),
|
||||
stopServiceIntent
|
||||
).build()
|
||||
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
|
||||
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
|
||||
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
|
||||
.setContentText(message)
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setSmallIcon(icon)
|
||||
.setContentIntent(openAppIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(stopAction)
|
||||
|
|
|
@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||
|
@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
|
|||
DELETE_SINGLE_PERMISSION_REQUEST,
|
||||
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
|
||||
CREATE_FILE_REQUEST,
|
||||
OPEN_FILE_REQUEST,
|
||||
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() {
|
|||
return
|
||||
}
|
||||
|
||||
// save access permissions across reboots
|
||||
val takeFlags = (data.flags
|
||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
// save access permissions across reboots
|
||||
val takeFlags = (data.flags
|
||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||
}
|
||||
|
||||
// resume pending action
|
||||
onStorageAccessResult(requestCode, treeUri)
|
||||
|
@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() {
|
|||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_MAIN -> {
|
||||
intent.getStringExtra("page")?.let { page ->
|
||||
var filters = intent.getStringArrayExtra("filters")?.toList()
|
||||
intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
|
||||
var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
|
||||
if (filters == null) {
|
||||
// fallback for shortcuts created on API < 26
|
||||
val filterString = intent.getStringExtra("filtersString")
|
||||
val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
|
||||
if (filterString != null) {
|
||||
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
||||
}
|
||||
}
|
||||
return hashMapOf(
|
||||
"page" to page,
|
||||
"filters" to filters,
|
||||
INTENT_DATA_KEY_PAGE to page,
|
||||
INTENT_DATA_KEY_FILTERS to filters,
|
||||
)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
|
||||
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
|
||||
return hashMapOf(
|
||||
"action" to "view",
|
||||
"uri" to uri.toString(),
|
||||
"mimeType" to intent.type, // MIME type is optional
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||
INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional
|
||||
INTENT_DATA_KEY_URI to uri.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
|
||||
return hashMapOf(
|
||||
"action" to "pick",
|
||||
"mimeType" to intent.type,
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
|
||||
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
||||
)
|
||||
}
|
||||
Intent.ACTION_SEARCH -> {
|
||||
val viewUri = intent.dataString
|
||||
return if (viewUri != null) hashMapOf(
|
||||
"action" to "view",
|
||||
"uri" to viewUri,
|
||||
"mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||
INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
|
||||
INTENT_DATA_KEY_URI to viewUri,
|
||||
) else hashMapOf(
|
||||
"action" to "search",
|
||||
"query" to intent.getStringExtra(SearchManager.QUERY),
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
|
||||
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
|
||||
)
|
||||
}
|
||||
Intent.ACTION_RUN -> {
|
||||
|
@ -261,7 +262,7 @@ class MainActivity : FlutterActivity() {
|
|||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search")
|
||||
.putExtra(SHORTCUT_KEY_PAGE, "/search")
|
||||
)
|
||||
.build()
|
||||
|
||||
|
@ -270,7 +271,7 @@ class MainActivity : FlutterActivity() {
|
|||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||
)
|
||||
.build()
|
||||
|
@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() {
|
|||
const val OPEN_FROM_ANALYSIS_SERVICE = 2
|
||||
const val CREATE_FILE_REQUEST = 3
|
||||
const val OPEN_FILE_REQUEST = 4
|
||||
const val SELECT_DIRECTORY_REQUEST = 5
|
||||
const val DELETE_SINGLE_PERMISSION_REQUEST = 6
|
||||
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
|
||||
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
|
||||
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
|
||||
|
||||
const val INTENT_DATA_KEY_ACTION = "action"
|
||||
const val INTENT_DATA_KEY_FILTERS = "filters"
|
||||
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
||||
const val INTENT_DATA_KEY_PAGE = "page"
|
||||
const val INTENT_DATA_KEY_URI = "uri"
|
||||
const val INTENT_DATA_KEY_QUERY = "query"
|
||||
|
||||
const val INTENT_ACTION_PICK = "pick"
|
||||
const val INTENT_ACTION_SEARCH = "search"
|
||||
const val INTENT_ACTION_VIEW = "view"
|
||||
|
||||
const val SHORTCUT_KEY_PAGE = "page"
|
||||
const val SHORTCUT_KEY_FILTERS_ARRAY = "filters"
|
||||
const val SHORTCUT_KEY_FILTERS_STRING = "filtersString"
|
||||
|
||||
// request code to pending runnable
|
||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||
|
|
|
@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
var removed = false
|
||||
try {
|
||||
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings", e)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
try {
|
||||
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||
}
|
||||
}
|
||||
result.success(removed)
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
|
|||
}
|
||||
|
||||
// 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)) {
|
||||
val intent = Intent(activity, AnalysisService::class.java)
|
||||
|
|
|
@ -18,6 +18,10 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
|
||||
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY
|
||||
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING
|
||||
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE
|
||||
import deckers.thibault.aves.R
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
|
@ -47,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
"openMap" -> safe(call, result, ::openMap)
|
||||
"setAs" -> safe(call, result, ::setAs)
|
||||
"share" -> safe(call, result, ::share)
|
||||
"canPin" -> safe(call, result, ::canPin)
|
||||
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
|
||||
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
fun addPackageDetails(intent: Intent) {
|
||||
// apps tend to use their name in English when creating directories
|
||||
// so we get their names in English as well as the current locale
|
||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
||||
val englishConfig = Configuration().apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
setLocale(Locale.ENGLISH)
|
||||
} else {
|
||||
@Suppress("deprecation")
|
||||
locale = Locale.ENGLISH
|
||||
}
|
||||
}
|
||||
|
||||
val pm = context.packageManager
|
||||
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
||||
|
@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
// shortcuts
|
||||
|
||||
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
|
||||
private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(isPinSupported())
|
||||
}
|
||||
|
||||
private fun pin(call: MethodCall, result: MethodChannel.Result) {
|
||||
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
||||
val label = call.argument<String>("label")
|
||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||
val filters = call.argument<List<String>>("filters")
|
||||
|
@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if (!isPinSupported()) {
|
||||
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
|
||||
return
|
||||
}
|
||||
|
@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
val intent = when {
|
||||
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
||||
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", filters.toTypedArray())
|
||||
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
|
||||
.putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
.putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
else -> {
|
||||
result.error("pin-intent", "failed to build intent", null)
|
||||
return
|
||||
|
|
|
@ -1,21 +1,42 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import java.util.*
|
||||
|
||||
class DeviceHandler : MethodCallHandler {
|
||||
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getCapabilities" -> safe(call, result, ::getCapabilities)
|
||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val sdkInt = Build.VERSION.SDK_INT
|
||||
result.success(
|
||||
hashMapOf(
|
||||
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
||||
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
||||
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||
// as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
|
||||
// but using hybrid composition would make it usable on API 19 too,
|
||||
// cf https://github.com/flutter/flutter/issues/23728
|
||||
"canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH),
|
||||
"hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().id)
|
||||
}
|
||||
|
|
|
@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
|
||||
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
|
||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
})
|
||||
}
|
||||
|
||||
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||
val iptc = call.argument<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) {
|
||||
val types = call.argument<List<String>>("types")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
|
|
|
@ -10,6 +10,8 @@ import android.provider.MediaStore
|
|||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.XMPMetaFactory
|
||||
import com.adobe.internal.xmp.options.SerializeOptions
|
||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.lang.KeyValuePair
|
||||
|
@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
|
@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
|
||||
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||
else -> result.notImplemented()
|
||||
|
@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
val kv = pair as KeyValuePair
|
||||
val key = kv.key
|
||||
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
|
||||
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset
|
||||
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
StandardCharsets.UTF_8
|
||||
} else {
|
||||
Charset.forName("UTF-8")
|
||||
}
|
||||
} else {
|
||||
kv.value.charset
|
||||
}
|
||||
val valueString = String(kv.value.bytes, charset)
|
||||
val dirs = extractPngProfile(key, valueString)
|
||||
if (dirs?.any() == true) {
|
||||
|
@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
try {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
||||
}
|
||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||
}
|
||||
|
@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val saveExposureTime: (value: Rational) -> Unit = {
|
||||
val saveExposureTime = fun(value: Rational) {
|
||||
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
|
||||
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
|
||||
// and process it to make sure the numerator is `1` when the ratio value is less than 1
|
||||
val num = it.numerator
|
||||
val denom = it.denominator
|
||||
val num = value.numerator
|
||||
val denom = value.denominator
|
||||
metadataMap[KEY_EXPOSURE_TIME] = when {
|
||||
num >= denom -> "${it.toSimpleString(true)}″"
|
||||
num >= denom -> "${value.toSimpleString(true)}″"
|
||||
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
|
||||
else -> it.toString()
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -734,6 +749,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
|
||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<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) {
|
||||
val prop = call.argument<String>("prop")
|
||||
if (prop == null) {
|
||||
|
@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
"XMP",
|
||||
)
|
||||
|
||||
private val xmpSerializeOptions = SerializeOptions().apply {
|
||||
omitPacketWrapper = true // e.g. <?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
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||
|
|
|
@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
try {
|
||||
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings", e)
|
||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||
}
|
||||
result.success(locked)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
|
|||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
|
@ -68,7 +69,12 @@ class RegionFetcher internal constructor(
|
|||
if (currentDecoderRef == null) {
|
||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
BitmapRegionDecoder.newInstance(input, false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(input)
|
||||
} else {
|
||||
@Suppress("deprecation")
|
||||
BitmapRegionDecoder.newInstance(input, false)
|
||||
}
|
||||
}
|
||||
if (newDecoder == null) {
|
||||
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
|
|||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
|
@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
if (update()) {
|
||||
success(
|
||||
hashMapOf(
|
||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
||||
)
|
||||
val settings: FieldMap = hashMapOf(
|
||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
|
||||
}
|
||||
success(settings)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,14 +51,15 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
accelerometerRotation = newAccelerometerRotation
|
||||
changed = true
|
||||
}
|
||||
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
|
||||
if (transitionAnimationScale != newTransitionAnimationScale) {
|
||||
transitionAnimationScale = newTransitionAnimationScale
|
||||
changed = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
|
||||
if (transitionAnimationScale != newTransitionAnimationScale) {
|
||||
transitionAnimationScale = newTransitionAnimationScale
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings", e)
|
||||
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
|
|||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
|
||||
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
}
|
||||
|
@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
}
|
||||
|
||||
private fun createFile() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
// TODO TLAD [<=API18] create file
|
||||
error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
|
||||
return
|
||||
}
|
||||
|
||||
val name = args["name"] as String?
|
||||
val mimeType = args["mimeType"] as String?
|
||||
val bytes = args["bytes"] as ByteArray?
|
||||
|
@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
|
||||
|
||||
private fun openFile() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
// TODO TLAD [<=API18] open file
|
||||
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
|
||||
return
|
||||
}
|
||||
|
||||
val mimeType = args["mimeType"] as String?
|
||||
if (mimeType == null) {
|
||||
error("openFile-args", "failed because of missing arguments", null)
|
||||
|
@ -158,24 +168,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
|
||||
}
|
||||
|
||||
private fun selectDirectory() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
|
||||
endOfStream()
|
||||
}, {
|
||||
success(null)
|
||||
endOfStream()
|
||||
})
|
||||
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
|
||||
} else {
|
||||
success(null)
|
||||
endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Any?) {
|
||||
|
|
|
@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
|
|||
val bitmapHeight: Int
|
||||
if (width / height > svgWidth / svgHeight) {
|
||||
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
||||
bitmapHeight = height;
|
||||
bitmapHeight = height
|
||||
} else {
|
||||
bitmapWidth = width
|
||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||
|
|
|
@ -223,7 +223,9 @@ object ExifInterfaceHelper {
|
|||
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
|
||||
|
||||
// exclude Exif directory when it only includes image size
|
||||
val isUselessExif: (Map<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 {
|
||||
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
|
||||
|
|
|
@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
|
||||
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
|
||||
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
||||
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
|
||||
}
|
||||
|
|
|
@ -32,12 +32,12 @@ object Metadata {
|
|||
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
||||
|
||||
// types of metadata
|
||||
const val TYPE_COMMENT = "comment"
|
||||
const val TYPE_EXIF = "exif"
|
||||
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||
const val TYPE_IPTC = "iptc"
|
||||
const val TYPE_JFIF = "jfif"
|
||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||
const val TYPE_JPEG_COMMENT = "jpeg_comment"
|
||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||
const val TYPE_XMP = "xmp"
|
||||
|
|
|
@ -54,9 +54,11 @@ object MultiPage {
|
|||
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
|
||||
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
|
||||
|
||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
|
||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
|
||||
}
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import pixy.meta.meta.Metadata
|
||||
import pixy.meta.meta.MetadataEntry
|
||||
import pixy.meta.meta.MetadataType
|
||||
import pixy.meta.meta.iptc.IPTC
|
||||
import pixy.meta.meta.iptc.IPTCDataSet
|
||||
import pixy.meta.meta.iptc.IPTCRecord
|
||||
import pixy.meta.meta.jpeg.JPGMeta
|
||||
import pixy.meta.meta.xmp.XMP
|
||||
import pixy.meta.string.XMLUtils
|
||||
|
@ -50,9 +54,46 @@ object PixyMetaHelper {
|
|||
return metadataMap
|
||||
}
|
||||
|
||||
fun getIptc(input: InputStream): List<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 setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
|
||||
fun setXmp(
|
||||
input: InputStream,
|
||||
output: OutputStream,
|
||||
xmpString: String?,
|
||||
extendedXmpString: String?
|
||||
) {
|
||||
if (extendedXmpString != null) {
|
||||
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
||||
} else {
|
||||
|
@ -70,12 +111,12 @@ object PixyMetaHelper {
|
|||
}
|
||||
|
||||
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||
TYPE_COMMENT -> MetadataType.COMMENT
|
||||
TYPE_EXIF -> MetadataType.EXIF
|
||||
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||
TYPE_IPTC -> MetadataType.IPTC
|
||||
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
|
||||
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||
TYPE_XMP -> MetadataType.XMP
|
||||
|
|
|
@ -23,7 +23,7 @@ object XMP {
|
|||
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
||||
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
||||
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
||||
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
||||
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
|||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.metadata.avi.AviDirectory
|
||||
|
@ -135,10 +136,12 @@ class SourceEntry {
|
|||
try {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
|
||||
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
|
||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
|
||||
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
} finally {
|
||||
|
|
|
@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
|
|||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
|
@ -460,6 +461,94 @@ abstract class ImageProvider {
|
|||
return true
|
||||
}
|
||||
|
||||
private fun editIptc(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
trailerDiff: Int = 0,
|
||||
iptc: List<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(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
@ -467,7 +556,9 @@ abstract class ImageProvider {
|
|||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
trailerDiff: Int = 0,
|
||||
edit: (xmp: String) -> String,
|
||||
coreXmp: String? = null,
|
||||
extendedXmp: String? = null,
|
||||
editCoreXmp: ((xmp: String) -> String)? = null,
|
||||
): Boolean {
|
||||
if (!canEditXmp(mimeType)) {
|
||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||
|
@ -479,18 +570,34 @@ abstract class ImageProvider {
|
|||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||
if (xmp == null) {
|
||||
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
|
||||
return false
|
||||
var editedXmpString = coreXmp
|
||||
var editedExtendedXmp = extendedXmp
|
||||
if (editCoreXmp != null) {
|
||||
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||
if (pixyXmp != null) {
|
||||
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
|
||||
if (pixyXmp.hasExtendedXmp()) {
|
||||
editedExtendedXmp = pixyXmp.extendedXmpDocString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val editedXmpString = edit(xmp.xmpDocString())
|
||||
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
|
||||
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
|
||||
if (editedXmpString != null) {
|
||||
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
|
||||
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
|
||||
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
|
||||
} else {
|
||||
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
|
||||
}
|
||||
} else if (canRemoveMetadata(mimeType)) {
|
||||
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
|
||||
} else {
|
||||
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
|
||||
PixyMetaHelper.setXmp(input, output, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -538,7 +645,7 @@ abstract class ImageProvider {
|
|||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||
)
|
||||
val newTrailerOffset = trailerOffset + diff
|
||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
|
||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||
xmp.replace(
|
||||
// GCamera motion photo
|
||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
||||
|
@ -548,7 +655,7 @@ abstract class ImageProvider {
|
|||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun editOrientation(
|
||||
|
@ -679,6 +786,65 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
fun setIptc(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
postEditScan: Boolean,
|
||||
callback: ImageOpCallback,
|
||||
iptc: List<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(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
|
|
@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
||||
}
|
||||
|
||||
// the provided URI can point to the wrong media collection,
|
||||
// e.g. a GIF image with the URI `content://media/external/video/media/[ID]`
|
||||
// so the effective entry URI may not match the provided URI
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
var found = false
|
||||
val fetched = arrayListOf<FieldMap>()
|
||||
val id = uri.tryParseId()
|
||||
val onSuccess = fun(entry: FieldMap) {
|
||||
entry["uri"] = uri.toString()
|
||||
fetched.add(entry)
|
||||
}
|
||||
val alwaysValid = { _: Int, _: Int -> true }
|
||||
val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
|
||||
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
||||
if (id != null) {
|
||||
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
|
||||
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
|
||||
|
@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
||||
val foundContentIds = ArrayList<Int>()
|
||||
val foundContentIds = HashSet<Int>()
|
||||
fun check(context: Context, contentUri: Uri) {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
try {
|
||||
|
|
|
@ -23,7 +23,7 @@ object FlutterUtils {
|
|||
}
|
||||
|
||||
lateinit var flutterLoader: FlutterLoader
|
||||
FlutterUtils.runOnUiThread {
|
||||
runOnUiThread {
|
||||
// initialization must happen on the main thread
|
||||
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
|
||||
startInitialization(context)
|
||||
|
|
|
@ -110,7 +110,16 @@ object MimeTypes {
|
|||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
|
||||
fun canEditIptc(mimeType: String) = when (mimeType) {
|
||||
JPEG, TIFF -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canEditXmp(mimeType: String) = when (mimeType) {
|
||||
JPEG, TIFF, PNG, GIF -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||
|
|
|
@ -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 {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
directories.all {
|
||||
|
@ -195,12 +162,14 @@ object PermissionManager {
|
|||
} ?: false
|
||||
}
|
||||
|
||||
// returns paths matching URIs granted by the user
|
||||
// returns paths matching directory URIs granted by the user
|
||||
fun getGrantedDirs(context: Context): Set<String> {
|
||||
val grantedDirs = HashSet<String>()
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||
dirPath?.let { grantedDirs.add(it) }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||
dirPath?.let { grantedDirs.add(it) }
|
||||
}
|
||||
}
|
||||
return grantedDirs
|
||||
}
|
||||
|
@ -208,13 +177,53 @@ object PermissionManager {
|
|||
// returns paths accessible to the app (granted by the user or by default)
|
||||
private fun getAccessibleDirs(context: Context): Set<String> {
|
||||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||
// from Android R, we no longer have access permission by default on primary volume
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
|
||||
// until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage
|
||||
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
|
||||
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
|
||||
// from API 30 / Android 11 / R, any storage requires access permission
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
|
||||
accessibleDirs.addAll(StorageUtils.getVolumePaths(context))
|
||||
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
|
||||
}
|
||||
return accessibleDirs
|
||||
}
|
||||
|
||||
fun getRestrictedDirectories(context: Context): List<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
|
||||
// URI permissions we hold points to a folder that no longer exists,
|
||||
// so we should remove these obsolete URIs before proceeding.
|
||||
|
@ -234,6 +243,7 @@ object PermissionManager {
|
|||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
private fun releaseUriPermission(context: Context, it: Uri) {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
|
|
10
android/app/src/main/res/values-fr/strings.xml
Normal file
10
android/app/src/main/res/values-fr/strings.xml
Normal 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 & vidéos</string>
|
||||
<string name="analysis_notification_default_title">Analyse des images</string>
|
||||
<string name="analysis_notification_action_stop">Annuler</string>
|
||||
</resources>
|
|
@ -1,6 +1,6 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.31'
|
||||
ext.kotlin_version = '1.6.0'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
|
|
@ -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.
|
||||
|
||||
<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>.
|
|
@ -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
|
|
@ -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.
|
||||
|
||||
<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 |
|
@ -53,6 +53,8 @@
|
|||
"@hideTooltip": {},
|
||||
"removeTooltip": "Remove",
|
||||
"@removeTooltip": {},
|
||||
"resetButtonTooltip": "Reset",
|
||||
"@resetButtonTooltip": {},
|
||||
|
||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||
"@doubleBackExitMessage": {},
|
||||
|
@ -145,6 +147,8 @@
|
|||
|
||||
"entryInfoActionEditDate": "Edit date & time",
|
||||
"@entryInfoActionEditDate": {},
|
||||
"entryInfoActionEditTags": "Edit tags",
|
||||
"@entryInfoActionEditTags": {},
|
||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||
"@entryInfoActionRemoveMetadata": {},
|
||||
|
||||
|
@ -417,7 +421,7 @@
|
|||
"removeEntryMetadataDialogMore": "More",
|
||||
"@removeEntryMetadataDialogMore": {},
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?",
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
|
||||
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
|
||||
|
||||
"videoSpeedDialogLabel": "Playback speed",
|
||||
|
@ -501,6 +505,17 @@
|
|||
"@aboutCreditsWorldAtlas2": {},
|
||||
"aboutCreditsTranslators": "Translators:",
|
||||
"@aboutCreditsTranslators": {},
|
||||
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||
"@aboutCreditsTranslatorLine": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
},
|
||||
"names": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"aboutLicenses": "Open-Source Licenses",
|
||||
"@aboutLicenses": {},
|
||||
|
@ -646,6 +661,8 @@
|
|||
"@drawerCollectionImages": {},
|
||||
"drawerCollectionVideos": "Videos",
|
||||
"@drawerCollectionVideos": {},
|
||||
"drawerCollectionAnimated": "Animated",
|
||||
"@drawerCollectionAnimated": {},
|
||||
"drawerCollectionMotionPhotos": "Motion photos",
|
||||
"@drawerCollectionMotionPhotos": {},
|
||||
"drawerCollectionPanoramas": "Panoramas",
|
||||
|
@ -791,20 +808,10 @@
|
|||
|
||||
"settingsSectionViewer": "Viewer",
|
||||
"@settingsSectionViewer": {},
|
||||
"settingsViewerShowOverlayOnOpening": "Show overlay on opening",
|
||||
"@settingsViewerShowOverlayOnOpening": {},
|
||||
"settingsViewerShowMinimap": "Show minimap",
|
||||
"@settingsViewerShowMinimap": {},
|
||||
"settingsViewerShowInformation": "Show information",
|
||||
"@settingsViewerShowInformation": {},
|
||||
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
|
||||
"@settingsViewerShowInformationSubtitle": {},
|
||||
"settingsViewerShowShootingDetails": "Show shooting details",
|
||||
"@settingsViewerShowShootingDetails": {},
|
||||
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
|
||||
"@settingsViewerEnableOverlayBlurEffect": {},
|
||||
"settingsViewerUseCutout": "Use cutout area",
|
||||
"@settingsViewerUseCutout": {},
|
||||
"settingsViewerMaximumBrightness": "Maximum brightness",
|
||||
"@settingsViewerMaximumBrightness": {},
|
||||
"settingsImageBackground": "Image background",
|
||||
"@settingsImageBackground": {},
|
||||
|
||||
|
@ -821,6 +828,23 @@
|
|||
"settingsViewerQuickActionEmpty": "No buttons",
|
||||
"@settingsViewerQuickActionEmpty": {},
|
||||
|
||||
"settingsViewerOverlayTile": "Overlay",
|
||||
"@settingsViewerOverlayTile": {},
|
||||
"settingsViewerOverlayTitle": "Overlay",
|
||||
"@settingsViewerOverlayTitle": {},
|
||||
"settingsViewerShowOverlayOnOpening": "Show on opening",
|
||||
"@settingsViewerShowOverlayOnOpening": {},
|
||||
"settingsViewerShowMinimap": "Show minimap",
|
||||
"@settingsViewerShowMinimap": {},
|
||||
"settingsViewerShowInformation": "Show information",
|
||||
"@settingsViewerShowInformation": {},
|
||||
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
|
||||
"@settingsViewerShowInformationSubtitle": {},
|
||||
"settingsViewerShowShootingDetails": "Show shooting details",
|
||||
"@settingsViewerShowShootingDetails": {},
|
||||
"settingsViewerEnableOverlayBlurEffect": "Blur effect",
|
||||
"@settingsViewerEnableOverlayBlurEffect": {},
|
||||
|
||||
"settingsVideoPageTitle": "Video Settings",
|
||||
"@settingsVideoPageTitle": {},
|
||||
"settingsSectionVideo": "Video",
|
||||
|
@ -880,8 +904,11 @@
|
|||
"settingsSaveSearchHistory": "Save search history",
|
||||
"@settingsSaveSearchHistory": {},
|
||||
|
||||
"settingsHiddenFiltersTile": "Hidden filters",
|
||||
"@settingsHiddenFiltersTile": {},
|
||||
"settingsHiddenItemsTile": "Hidden items",
|
||||
"@settingsHiddenItemsTile": {},
|
||||
"settingsHiddenItemsTitle": "Hidden Items",
|
||||
"@settingsHiddenItemsTitle": {},
|
||||
|
||||
"settingsHiddenFiltersTitle": "Hidden Filters",
|
||||
"@settingsHiddenFiltersTitle": {},
|
||||
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
|
||||
|
@ -889,14 +916,10 @@
|
|||
"settingsHiddenFiltersEmpty": "No hidden filters",
|
||||
"@settingsHiddenFiltersEmpty": {},
|
||||
|
||||
"settingsHiddenPathsTile": "Hidden paths",
|
||||
"@settingsHiddenPathsTile": {},
|
||||
"settingsHiddenPathsTitle": "Hidden Paths",
|
||||
"@settingsHiddenPathsTitle": {},
|
||||
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
|
||||
"@settingsHiddenPathsBanner": {},
|
||||
"settingsHiddenPathsEmpty": "No hidden paths",
|
||||
"@settingsHiddenPathsEmpty": {},
|
||||
"addPathTooltip": "Add path",
|
||||
"@addPathTooltip": {},
|
||||
|
||||
|
@ -1026,11 +1049,31 @@
|
|||
"viewerInfoSearchSuggestionRights": "Rights",
|
||||
"@viewerInfoSearchSuggestionRights": {},
|
||||
|
||||
"tagEditorPageTitle": "Edit Tags",
|
||||
"@tagEditorPageTitle": {},
|
||||
"tagEditorPageNewTagFieldLabel": "New tag",
|
||||
"@tagEditorPageNewTagFieldLabel": {},
|
||||
"tagEditorPageAddTagTooltip": "Add tag",
|
||||
"@tagEditorPageAddTagTooltip": {},
|
||||
"tagEditorSectionRecent": "Recent",
|
||||
"@tagEditorSectionRecent": {},
|
||||
|
||||
"panoramaEnableSensorControl": "Enable sensor control",
|
||||
"@panoramaEnableSensorControl": {},
|
||||
"panoramaDisableSensorControl": "Disable sensor control",
|
||||
"@panoramaDisableSensorControl": {},
|
||||
|
||||
"sourceViewerPageTitle": "Source",
|
||||
"@sourceViewerPageTitle": {}
|
||||
"@sourceViewerPageTitle": {},
|
||||
|
||||
"filePickerShowHiddenFiles": "Show hidden files",
|
||||
"@filePickerShowHiddenFiles": {},
|
||||
"filePickerDoNotShowHiddenFiles": "Don’t show hidden files",
|
||||
"@filePickerDoNotShowHiddenFiles": {},
|
||||
"filePickerOpenFrom": "Open from",
|
||||
"@filePickerOpenFrom": {},
|
||||
"filePickerNoItems": "No items",
|
||||
"@filePickerNoItems": {},
|
||||
"filePickerUseThisFolder": "Use this folder",
|
||||
"@filePickerUseThisFolder": {}
|
||||
}
|
||||
|
|
523
lib/l10n/app_fr.arb
Normal file
523
lib/l10n/app_fr.arb
Normal file
|
@ -0,0 +1,523 @@
|
|||
{
|
||||
"appName": "Aves",
|
||||
"welcomeMessage": "Bienvenue",
|
||||
"welcomeOptional": "Option",
|
||||
"welcomeTermsToggle": "J’accepte les conditions d’utilisation",
|
||||
"itemCount": "{count, plural, =1{1 élément} other{{count} éléments}}",
|
||||
|
||||
"timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
|
||||
|
||||
"applyButtonLabel": "ENREGISTRER",
|
||||
"deleteButtonLabel": "SUPPRIMER",
|
||||
"nextButtonLabel": "SUIVANT",
|
||||
"showButtonLabel": "AFFICHER",
|
||||
"hideButtonLabel": "MASQUER",
|
||||
"continueButtonLabel": "CONTINUER",
|
||||
|
||||
"changeTooltip": "Modifier",
|
||||
"clearTooltip": "Effacer",
|
||||
"previousTooltip": "Précédent",
|
||||
"nextTooltip": "Suivant",
|
||||
"showTooltip": "Afficher",
|
||||
"hideTooltip": "Masquer",
|
||||
"removeTooltip": "Supprimer",
|
||||
"resetButtonTooltip": "Réinitialiser",
|
||||
|
||||
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
|
||||
|
||||
"sourceStateLoading": "Chargement",
|
||||
"sourceStateCataloguing": "Classification",
|
||||
"sourceStateLocatingCountries": "Identification des pays",
|
||||
"sourceStateLocatingPlaces": "Identification des lieux",
|
||||
|
||||
"chipActionDelete": "Supprimer",
|
||||
"chipActionGoToAlbumPage": "Afficher dans Albums",
|
||||
"chipActionGoToCountryPage": "Afficher dans Pays",
|
||||
"chipActionGoToTagPage": "Afficher dans Libellés",
|
||||
"chipActionHide": "Masquer",
|
||||
"chipActionPin": "Épingler",
|
||||
"chipActionUnpin": "Retirer",
|
||||
"chipActionRename": "Renommer",
|
||||
"chipActionSetCover": "Modifier la couverture",
|
||||
"chipActionCreateAlbum": "Créer un album",
|
||||
|
||||
"entryActionCopyToClipboard": "Copier dans presse-papier",
|
||||
"entryActionDelete": "Supprimer",
|
||||
"entryActionExport": "Exporter",
|
||||
"entryActionInfo": "Détails",
|
||||
"entryActionRename": "Renommer",
|
||||
"entryActionRotateCCW": "Pivoter à gauche",
|
||||
"entryActionRotateCW": "Pivoter à droite",
|
||||
"entryActionFlip": "Retourner horizontalement",
|
||||
"entryActionPrint": "Imprimer",
|
||||
"entryActionShare": "Partager",
|
||||
"entryActionViewSource": "Voir le code",
|
||||
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
|
||||
"entryActionEdit": "Modifier avec…",
|
||||
"entryActionOpen": "Ouvrir avec…",
|
||||
"entryActionSetAs": "Utiliser comme…",
|
||||
"entryActionOpenMap": "Localiser avec…",
|
||||
"entryActionRotateScreen": "Pivoter l’écran",
|
||||
"entryActionAddFavourite": "Ajouter aux favoris",
|
||||
"entryActionRemoveFavourite": "Retirer des favoris",
|
||||
|
||||
"videoActionCaptureFrame": "Capturer l’image",
|
||||
"videoActionPause": "Pause",
|
||||
"videoActionPlay": "Lire",
|
||||
"videoActionReplay10": "Reculer de 10 secondes",
|
||||
"videoActionSkip10": "Avancer de 10 secondes",
|
||||
"videoActionSelectStreams": "Choisir les pistes",
|
||||
"videoActionSetSpeed": "Vitesse de lecture",
|
||||
"videoActionSettings": "Préférences",
|
||||
|
||||
"entryInfoActionEditDate": "Modifier la date",
|
||||
"entryInfoActionEditTags": "Modifier les libellés",
|
||||
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
|
||||
|
||||
"filterFavouriteLabel": "Favori",
|
||||
"filterLocationEmptyLabel": "Sans lieu",
|
||||
"filterTagEmptyLabel": "Sans libellé",
|
||||
"filterTypeAnimatedLabel": "Animation",
|
||||
"filterTypeMotionPhotoLabel": "Photo animée",
|
||||
"filterTypePanoramaLabel": "Panorama",
|
||||
"filterTypeRawLabel": "Raw",
|
||||
"filterTypeSphericalVideoLabel": "Vidéo à 360°",
|
||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||
"filterMimeImageLabel": "Image",
|
||||
"filterMimeVideoLabel": "Vidéo",
|
||||
|
||||
"coordinateFormatDms": "DMS",
|
||||
"coordinateFormatDecimal": "Degrés décimaux",
|
||||
"coordinateDms": "{coordinate} {direction}",
|
||||
"coordinateDmsNorth": "N",
|
||||
"coordinateDmsSouth": "S",
|
||||
"coordinateDmsEast": "E",
|
||||
"coordinateDmsWest": "O",
|
||||
|
||||
"unitSystemMetric": "SI",
|
||||
"unitSystemImperial": "anglo-saxonnes",
|
||||
|
||||
"videoLoopModeNever": "Jamais",
|
||||
"videoLoopModeShortOnly": "Courtes vidéos seulement",
|
||||
"videoLoopModeAlways": "Toujours",
|
||||
|
||||
"mapStyleGoogleNormal": "Google Maps",
|
||||
"mapStyleGoogleHybrid": "Google Maps (Satellite)",
|
||||
"mapStyleGoogleTerrain": "Google Maps (Relief)",
|
||||
"mapStyleOsmHot": "OSM Humanitaire",
|
||||
"mapStyleStamenToner": "Stamen Toner",
|
||||
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
||||
|
||||
"nameConflictStrategyRename": "Renommer",
|
||||
"nameConflictStrategyReplace": "Remplacer",
|
||||
"nameConflictStrategySkip": "Ignorer",
|
||||
|
||||
"keepScreenOnNever": "Jamais",
|
||||
"keepScreenOnViewerOnly": "Visionneuse seulement",
|
||||
"keepScreenOnAlways": "Toujours",
|
||||
|
||||
"accessibilityAnimationsRemove": "Empêchez certains effets de l’écran",
|
||||
"accessibilityAnimationsKeep": "Conserver les effets de l’écran",
|
||||
|
||||
"albumTierNew": "Nouveaux",
|
||||
"albumTierPinned": "Épinglés",
|
||||
"albumTierSpecial": "Standards",
|
||||
"albumTierApps": "Apps",
|
||||
"albumTierRegular": "Autres",
|
||||
|
||||
"storageVolumeDescriptionFallbackPrimary": "Stockage interne",
|
||||
"storageVolumeDescriptionFallbackNonPrimary": "Carte SD",
|
||||
"rootDirectoryDescription": "dossier racine",
|
||||
"otherDirectoryDescription": "dossier «\u00A0{name}\u00A0»",
|
||||
"storageAccessDialogTitle": "Accès au dossier",
|
||||
"storageAccessDialogMessage": "Veuillez sélectionner le {directory} de «\u00A0{volume}\u00A0» à l’écran suivant, pour que l’app puisse modifier son contenu.",
|
||||
"restrictedAccessDialogTitle": "Accès restreint",
|
||||
"restrictedAccessDialogMessage": "Cette app ne peut pas modifier les fichiers du {directory} de «\u00A0{volume}\u00A0».\n\nVeuillez utiliser une app pré-installée pour déplacer les fichiers vers un autre dossier.",
|
||||
"notEnoughSpaceDialogTitle": "Espace insuffisant",
|
||||
"notEnoughSpaceDialogMessage": "Cette opération nécessite {neededSize} d’espace disponible sur «\u00A0{volume}\u00A0», mais il ne reste que {freeSize}.",
|
||||
|
||||
"unsupportedTypeDialogTitle": "Formats non supportés",
|
||||
"unsupportedTypeDialogMessage": "{count, plural, =1{Cette opération n’est pas disponible pour les fichiers au format suivant : {types}.} other{Cette opération n’est pas disponible pour les fichiers aux formats suivants : {types}.}}",
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "Certains fichiers dans le dossier de destination ont le même nom.",
|
||||
"nameConflictDialogMultipleSourceMessage": "Certains fichiers ont le même nom.",
|
||||
|
||||
"addShortcutDialogLabel": "Nom du raccourci",
|
||||
"addShortcutButtonLabel": "AJOUTER",
|
||||
|
||||
"noMatchingAppDialogTitle": "App indisponible",
|
||||
"noMatchingAppDialogMessage": "Aucune app ne peut effectuer cette opération.",
|
||||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet élément ?} other{Voulez-vous vraiment supprimer ces {count} éléments ?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?",
|
||||
"videoStartOverButtonLabel": "RECOMMENCER",
|
||||
"videoResumeButtonLabel": "REPRENDRE",
|
||||
|
||||
"setCoverDialogTitle": "Modifier la couverture",
|
||||
"setCoverDialogLatest": "dernier élément",
|
||||
"setCoverDialogCustom": "personnalisé",
|
||||
|
||||
"hideFilterConfirmationDialogMessage": "Les images et vidéos correspondantes n’apparaîtront plus dans votre collection. Vous pouvez les montrer à nouveau via les réglages de «\u00A0Confidentialité\u00A0».\n\nVoulez-vous vraiment les masquer ?",
|
||||
|
||||
"newAlbumDialogTitle": "Nouvel Album",
|
||||
"newAlbumDialogNameLabel": "Nom de l’album",
|
||||
"newAlbumDialogNameLabelAlreadyExistsHelper": "Le dossier existe déjà",
|
||||
"newAlbumDialogStorageLabel": "Volume de stockage :",
|
||||
|
||||
"renameAlbumDialogLabel": "Nouveau nom",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet album et son élément ?} other{Voulez-vous vraiment supprimer cet album et ses {count} éléments ?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
|
||||
|
||||
"exportEntryDialogFormat": "Format :",
|
||||
|
||||
"renameEntryDialogLabel": "Nouveau nom",
|
||||
|
||||
"editEntryDateDialogTitle": "Date & Heure",
|
||||
"editEntryDateDialogSet": "Régler",
|
||||
"editEntryDateDialogShift": "Décaler",
|
||||
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
|
||||
"editEntryDateDialogClear": "Effacer",
|
||||
"editEntryDateDialogFieldSelection": "Champs affectés",
|
||||
"editEntryDateDialogHours": "Heures",
|
||||
"editEntryDateDialogMinutes": "Minutes",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Retrait de métadonnées",
|
||||
"removeEntryMetadataDialogMore": "Voir plus",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Les métadonnées XMP sont nécessaires pour jouer la vidéo d’une photo animée.\n\nVoulez-vous vraiment les supprimer ?",
|
||||
|
||||
"videoSpeedDialogLabel": "Vitesse de lecture",
|
||||
|
||||
"videoStreamSelectionDialogVideo": "Vidéo",
|
||||
"videoStreamSelectionDialogAudio": "Audio",
|
||||
"videoStreamSelectionDialogText": "Sous-titres",
|
||||
"videoStreamSelectionDialogOff": "Désactivé",
|
||||
"videoStreamSelectionDialogTrack": "Piste",
|
||||
"videoStreamSelectionDialogNoSelection": "Il n’y a pas d’autre piste.",
|
||||
|
||||
"genericSuccessFeedback": "Succès !",
|
||||
"genericFailureFeedback": "Échec",
|
||||
|
||||
"menuActionSort": "Trier",
|
||||
"menuActionGroup": "Grouper",
|
||||
"menuActionSelect": "Sélectionner",
|
||||
"menuActionSelectAll": "Tout sélectionner",
|
||||
"menuActionSelectNone": "Tout désélectionner",
|
||||
"menuActionMap": "Carte",
|
||||
"menuActionStats": "Statistiques",
|
||||
|
||||
"aboutPageTitle": "À propos",
|
||||
"aboutLinkSources": "Sources",
|
||||
"aboutLinkLicense": "Licence",
|
||||
"aboutLinkPolicy": "Politique de confidentialité",
|
||||
|
||||
"aboutUpdate": "Nouvelle Version",
|
||||
"aboutUpdateLinks1": "Une nouvelle version d’Aves est disponible sur",
|
||||
"aboutUpdateLinks2": "et",
|
||||
"aboutUpdateLinks3": ".",
|
||||
"aboutUpdateGitHub": "GitHub",
|
||||
"aboutUpdateGooglePlay": "Google Play",
|
||||
|
||||
"aboutBug": "Rapports d’erreur",
|
||||
"aboutBugSaveLogInstruction": "Sauvegarder les logs de l’app vers un fichier",
|
||||
"aboutBugSaveLogButton": "Sauvegarder",
|
||||
"aboutBugCopyInfoInstruction": "Copier les informations d’environnement",
|
||||
"aboutBugCopyInfoButton": "Copier",
|
||||
"aboutBugReportInstruction": "Créer une «\u00A0issue\u00A0» sur GitHub en attachant les logs et informations d’environnement",
|
||||
"aboutBugReportButton": "Créer",
|
||||
|
||||
"aboutCredits": "Remerciements",
|
||||
"aboutCreditsWorldAtlas1": "Cette app utilise un fichier TopoJSON de ",
|
||||
"aboutCreditsWorldAtlas2": "sous licence ISC.",
|
||||
"aboutCreditsTranslators": "Traducteurs :",
|
||||
"aboutCreditsTranslatorLine": "{language} : {names}",
|
||||
|
||||
"aboutLicenses": "Licences open-source",
|
||||
"aboutLicensesBanner": "Cette app utilise ces librairies et packages open-source.",
|
||||
"aboutLicensesAndroidLibraries": "Librairies Android",
|
||||
"aboutLicensesFlutterPlugins": "Plugins Flutter",
|
||||
"aboutLicensesFlutterPackages": "Packages Flutter",
|
||||
"aboutLicensesDartPackages": "Packages Dart",
|
||||
"aboutLicensesShowAllButtonLabel": "Afficher toutes les licences",
|
||||
|
||||
"policyPageTitle": "Politique de confidentialité",
|
||||
|
||||
"collectionPageTitle": "Collection",
|
||||
"collectionPickPageTitle": "Sélection",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Sélection} =1{1 élément} other{{count} éléments}}",
|
||||
|
||||
"collectionActionShowTitleSearch": "Filtrer les titres",
|
||||
"collectionActionHideTitleSearch": "Masquer le filtre",
|
||||
"collectionActionAddShortcut": "Créer un raccourci",
|
||||
"collectionActionCopy": "Copier vers l’album",
|
||||
"collectionActionMove": "Déplacer vers l’album",
|
||||
"collectionActionRescan": "Réanalyser",
|
||||
"collectionActionEdit": "Modifier",
|
||||
|
||||
"collectionSearchTitlesHintText": "Recherche de titres",
|
||||
|
||||
"collectionSortTitle": "Trier",
|
||||
"collectionSortDate": "par date",
|
||||
"collectionSortSize": "par taille",
|
||||
"collectionSortName": "alphabétiquement",
|
||||
|
||||
"collectionGroupTitle": "Grouper",
|
||||
"collectionGroupAlbum": "par album",
|
||||
"collectionGroupMonth": "par mois",
|
||||
"collectionGroupDay": "par jour",
|
||||
"collectionGroupNone": "ne pas grouper",
|
||||
|
||||
"sectionUnknown": "Inconnu",
|
||||
"dateToday": "Aujourd’hui",
|
||||
"dateYesterday": "Hier",
|
||||
"dateThisMonth": "Ce mois-ci",
|
||||
"collectionDeleteFailureFeedback": "{count, plural, =1{Échec de la suppression d’1 élément} other{Échec de la suppression de {count} éléments}}",
|
||||
"collectionCopyFailureFeedback": "{count, plural, =1{Échec de la copie d’1 élément} other{Échec de la copie de {count} éléments}}",
|
||||
"collectionMoveFailureFeedback": "{count, plural, =1{Échec du déplacement d’1 élément} other{Échec du déplacement de {count} éléments}}",
|
||||
"collectionEditFailureFeedback": "{count, plural, =1{Échec de la modification d’1 élément} other{Échec de la modification de {count} éléments}}",
|
||||
"collectionExportFailureFeedback": "{count, plural, =1{Échec de l’export d’1 page} other{Échec de l’export de {count} pages}}",
|
||||
"collectionCopySuccessFeedback": "{count, plural, =1{1 élément copié} other{{count} éléments copiés}}",
|
||||
"collectionMoveSuccessFeedback": "{count, plural, =1{1 élément déplacé} other{{count} éléments déplacés}}",
|
||||
"collectionEditSuccessFeedback": "{count, plural, =1{1 élément modifié} other{{count} éléments modifiés}}",
|
||||
|
||||
"collectionEmptyFavourites": "Aucun favori",
|
||||
"collectionEmptyVideos": "Aucune vidéo",
|
||||
"collectionEmptyImages": "Aucune image",
|
||||
|
||||
"collectionSelectSectionTooltip": "Sélectionner la section",
|
||||
"collectionDeselectSectionTooltip": "Désélectionner la section",
|
||||
|
||||
"drawerCollectionAll": "Toute la collection",
|
||||
"drawerCollectionFavourites": "Favoris",
|
||||
"drawerCollectionImages": "Images",
|
||||
"drawerCollectionVideos": "Vidéos",
|
||||
"drawerCollectionAnimated": "Animations",
|
||||
"drawerCollectionMotionPhotos": "Photos animées",
|
||||
"drawerCollectionPanoramas": "Panoramas",
|
||||
"drawerCollectionRaws": "Photos Raw",
|
||||
"drawerCollectionSphericalVideos": "Vidéos à 360°",
|
||||
|
||||
"chipSortTitle": "Trier",
|
||||
"chipSortDate": "par date",
|
||||
"chipSortName": "par nom",
|
||||
"chipSortCount": "par nombre d’éléments",
|
||||
|
||||
"albumGroupTitle": "Grouper",
|
||||
"albumGroupTier": "par importance",
|
||||
"albumGroupVolume": "par volume de stockage",
|
||||
"albumGroupNone": "ne pas grouper",
|
||||
|
||||
"albumPickPageTitleCopy": "Copie",
|
||||
"albumPickPageTitleExport": "Export",
|
||||
"albumPickPageTitleMove": "Déplacement",
|
||||
"albumPickPageTitlePick": "Sélection",
|
||||
|
||||
"albumCamera": "Appareil photo",
|
||||
"albumDownload": "Téléchargements",
|
||||
"albumScreenshots": "Captures d’écran",
|
||||
"albumScreenRecordings": "Enregistrements d’écran",
|
||||
"albumVideoCaptures": "Captures de vidéo",
|
||||
|
||||
"albumPageTitle": "Albums",
|
||||
"albumEmpty": "Aucun album",
|
||||
"createAlbumTooltip": "Créer un album",
|
||||
"createAlbumButtonLabel": "CRÉER",
|
||||
"newFilterBanner": "nouveau",
|
||||
|
||||
"countryPageTitle": "Pays",
|
||||
"countryEmpty": "Aucun pays",
|
||||
|
||||
"tagPageTitle": "Libellés",
|
||||
"tagEmpty": "Aucun libellé",
|
||||
|
||||
"searchCollectionFieldHint": "Recherche",
|
||||
"searchSectionRecent": "Historique",
|
||||
"searchSectionAlbums": "Albums",
|
||||
"searchSectionCountries": "Pays",
|
||||
"searchSectionPlaces": "Lieux",
|
||||
"searchSectionTags": "Libellés",
|
||||
|
||||
"settingsPageTitle": "Réglages",
|
||||
"settingsSystemDefault": "Système",
|
||||
"settingsDefault": "Par défaut",
|
||||
|
||||
"settingsActionExport": "Exporter",
|
||||
"settingsActionImport": "Importer",
|
||||
|
||||
"settingsSectionNavigation": "Navigation",
|
||||
"settingsHome": "Page d’accueil",
|
||||
"settingsKeepScreenOnTile": "Maintenir l’écran allumé",
|
||||
"settingsKeepScreenOnTitle": "Allumage de l’écran",
|
||||
"settingsDoubleBackExit": "Presser «\u00A0retour\u00A0» 2 fois pour quitter",
|
||||
|
||||
"settingsNavigationDrawerTile": "Menu de navigation",
|
||||
"settingsNavigationDrawerEditorTitle": "Menu de navigation",
|
||||
"settingsNavigationDrawerBanner": "Maintenez votre doigt appuyé pour déplacer et réorganiser les éléments de menu.",
|
||||
"settingsNavigationDrawerTabTypes": "Types",
|
||||
"settingsNavigationDrawerTabAlbums": "Albums",
|
||||
"settingsNavigationDrawerTabPages": "Pages",
|
||||
"settingsNavigationDrawerAddAlbum": "Ajouter un album",
|
||||
|
||||
"settingsSectionThumbnails": "Vignettes",
|
||||
"settingsThumbnailShowLocationIcon": "Afficher l’icône de lieu",
|
||||
"settingsThumbnailShowMotionPhotoIcon": "Afficher l’icône de photo animée",
|
||||
"settingsThumbnailShowRawIcon": "Afficher l’icône de photo raw",
|
||||
"settingsThumbnailShowVideoDuration": "Afficher la durée de la vidéo",
|
||||
|
||||
"settingsCollectionQuickActionsTile": "Actions rapides",
|
||||
"settingsCollectionQuickActionEditorTitle": "Actions rapides",
|
||||
"settingsCollectionQuickActionTabBrowsing": "Navigation",
|
||||
"settingsCollectionQuickActionTabSelecting": "Sélection",
|
||||
"settingsCollectionBrowsingQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la navigation.",
|
||||
"settingsCollectionSelectionQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées lors de la sélection d’éléments.",
|
||||
|
||||
"settingsSectionViewer": "Visionneuse",
|
||||
"settingsViewerUseCutout": "Utiliser la zone d’encoche",
|
||||
"settingsViewerMaximumBrightness": "Luminosité maximale",
|
||||
"settingsImageBackground": "Arrière-plan de l’image",
|
||||
|
||||
"settingsViewerQuickActionsTile": "Actions rapides",
|
||||
"settingsViewerQuickActionEditorTitle": "Actions rapides",
|
||||
"settingsViewerQuickActionEditorBanner": "Maintenez votre doigt appuyé pour déplacer les boutons et choisir les actions affichées sur la visionneuse.",
|
||||
"settingsViewerQuickActionEditorDisplayedButtons": "Boutons affichés",
|
||||
"settingsViewerQuickActionEditorAvailableButtons": "Boutons disponibles",
|
||||
"settingsViewerQuickActionEmpty": "Aucun bouton",
|
||||
|
||||
"settingsViewerOverlayTile": "Incrustations",
|
||||
"settingsViewerOverlayTitle": "Incrustations",
|
||||
"settingsViewerShowOverlayOnOpening": "Afficher à l’ouverture",
|
||||
"settingsViewerShowMinimap": "Afficher la mini-carte",
|
||||
"settingsViewerShowInformation": "Afficher les détails",
|
||||
"settingsViewerShowInformationSubtitle": "Afficher les titre, date, lieu, etc.",
|
||||
"settingsViewerShowShootingDetails": "Afficher les détails de prise de vue",
|
||||
"settingsViewerEnableOverlayBlurEffect": "Effets de flou",
|
||||
|
||||
"settingsVideoPageTitle": "Réglages vidéo",
|
||||
"settingsSectionVideo": "Vidéo",
|
||||
"settingsVideoShowVideos": "Afficher les vidéos",
|
||||
"settingsVideoEnableHardwareAcceleration": "Accélération matérielle",
|
||||
"settingsVideoEnableAutoPlay": "Lecture automatique",
|
||||
"settingsVideoLoopModeTile": "Lecture répétée",
|
||||
"settingsVideoLoopModeTitle": "Lecture répétée",
|
||||
"settingsVideoQuickActionsTile": "Actions rapides pour les vidéos",
|
||||
"settingsVideoQuickActionEditorTitle": "Actions rapides",
|
||||
|
||||
"settingsSubtitleThemeTile": "Sous-titres",
|
||||
"settingsSubtitleThemeTitle": "Sous-titres",
|
||||
"settingsSubtitleThemeSample": "Ceci est un exemple.",
|
||||
"settingsSubtitleThemeTextAlignmentTile": "Alignement du texte",
|
||||
"settingsSubtitleThemeTextAlignmentTitle": "Alignement du texte",
|
||||
"settingsSubtitleThemeTextSize": "Taille du texte",
|
||||
"settingsSubtitleThemeShowOutline": "Afficher les contours et ombres",
|
||||
"settingsSubtitleThemeTextColor": "Couleur du texte",
|
||||
"settingsSubtitleThemeTextOpacity": "Transparence du texte",
|
||||
"settingsSubtitleThemeBackgroundColor": "Couleur d’arrière-plan",
|
||||
"settingsSubtitleThemeBackgroundOpacity": "Transparence de l’arrière-plan",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "gauche",
|
||||
"settingsSubtitleThemeTextAlignmentCenter": "centré",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "droite",
|
||||
|
||||
"settingsSectionPrivacy": "Confidentialité",
|
||||
"settingsAllowInstalledAppAccess": "Autoriser l’accès à l’inventaire des apps",
|
||||
"settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums",
|
||||
"settingsAllowErrorReporting": "Autoriser l’envoi de rapports d’erreur",
|
||||
"settingsSaveSearchHistory": "Maintenir un historique des recherches",
|
||||
|
||||
"settingsHiddenItemsTile": "Éléments masqués",
|
||||
"settingsHiddenItemsTitle": "Éléments masqués",
|
||||
|
||||
"settingsHiddenFiltersTitle": "Filtres masqués",
|
||||
"settingsHiddenFiltersBanner": "Les images et vidéos correspondantes aux filtres masqués n’apparaîtront pas dans votre collection.",
|
||||
"settingsHiddenFiltersEmpty": "Aucun filtre masqué",
|
||||
|
||||
"settingsHiddenPathsTitle": "Chemins masqués",
|
||||
"settingsHiddenPathsBanner": "Les images et vidéos dans ces dossiers, ou leurs sous-dossiers, n’apparaîtront pas dans votre collection.",
|
||||
"addPathTooltip": "Ajouter un chemin",
|
||||
|
||||
"settingsStorageAccessTile": "Accès au stockage",
|
||||
"settingsStorageAccessTitle": "Accès au stockage",
|
||||
"settingsStorageAccessBanner": "Une autorisation d’accès au stockage est nécessaire pour modifier le contenu de certains dossiers. Voici la liste des autorisations couramment en vigueur.",
|
||||
"settingsStorageAccessEmpty": "Aucune autorisation d’accès",
|
||||
"settingsStorageAccessRevokeTooltip": "Retirer",
|
||||
|
||||
"settingsSectionAccessibility": "Accessibilité",
|
||||
"settingsRemoveAnimationsTile": "Suppression des animations",
|
||||
"settingsRemoveAnimationsTitle": "Suppression des animations",
|
||||
"settingsTimeToTakeActionTile": "Délai pour effectuer une action",
|
||||
"settingsTimeToTakeActionTitle": "Délai pour effectuer une action",
|
||||
|
||||
"settingsSectionLanguage": "Langue & Formats",
|
||||
"settingsLanguage": "Langue",
|
||||
"settingsCoordinateFormatTile": "Format de coordonnées",
|
||||
"settingsCoordinateFormatTitle": "Format de coordonnées",
|
||||
"settingsUnitSystemTile": "Unités",
|
||||
"settingsUnitSystemTitle": "Unités",
|
||||
|
||||
"statsPageTitle": "Statistiques",
|
||||
"statsWithGps": "{count, plural, =1{1 élément localisé} other{{count} éléments localisés}}",
|
||||
"statsTopCountries": "Top pays",
|
||||
"statsTopPlaces": "Top lieux",
|
||||
"statsTopTags": "Top libellés",
|
||||
|
||||
"viewerOpenPanoramaButtonLabel": "OUVRIR LE PANORAMA",
|
||||
"viewerErrorUnknown": "Zut !",
|
||||
"viewerErrorDoesNotExist": "Le fichier n’existe plus.",
|
||||
|
||||
"viewerInfoPageTitle": "Détails",
|
||||
"viewerInfoBackToViewerTooltip": "Retour à la visionneuse",
|
||||
|
||||
"viewerInfoUnknown": "inconnu",
|
||||
"viewerInfoLabelTitle": "Titre",
|
||||
"viewerInfoLabelDate": "Date",
|
||||
"viewerInfoLabelResolution": "Résolution",
|
||||
"viewerInfoLabelSize": "Taille",
|
||||
"viewerInfoLabelUri": "URI",
|
||||
"viewerInfoLabelPath": "Chemin",
|
||||
"viewerInfoLabelDuration": "Durée",
|
||||
"viewerInfoLabelOwner": "Propriétaire",
|
||||
"viewerInfoLabelCoordinates": "Coordonnées",
|
||||
"viewerInfoLabelAddress": "Adresse",
|
||||
|
||||
"mapStyleTitle": "Type de carte",
|
||||
"mapStyleTooltip": "Sélectionner le type de carte",
|
||||
"mapZoomInTooltip": "Zoomer",
|
||||
"mapZoomOutTooltip": "Dézoomer",
|
||||
"mapPointNorthUpTooltip": "Placer le nord en haut",
|
||||
"mapAttributionOsmHot": "Données © les contributeurs d’[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [HOT](https://www.hotosm.org/) • Hébergé par [OSM France](https://openstreetmap.fr/)",
|
||||
"mapAttributionStamen": "Données © les contributeurs d’[OpenStreetMap](https://www.openstreetmap.org/copyright) • Fond de carte par [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"openMapPageTooltip": "Ouvrir la page Carte",
|
||||
"mapEmptyRegion": "Aucune image dans cette région",
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "Échec de l’extraction des données",
|
||||
"viewerInfoOpenLinkText": "Ouvrir",
|
||||
"viewerInfoViewXmlLinkText": "Afficher le XML",
|
||||
|
||||
"viewerInfoSearchFieldLabel": "Recherche de métadonnées",
|
||||
"viewerInfoSearchEmpty": "Aucune clé correspondante",
|
||||
"viewerInfoSearchSuggestionDate": "Date & heure",
|
||||
"viewerInfoSearchSuggestionDescription": "Description",
|
||||
"viewerInfoSearchSuggestionDimensions": "Dimensions",
|
||||
"viewerInfoSearchSuggestionResolution": "Résolution",
|
||||
"viewerInfoSearchSuggestionRights": "Droits",
|
||||
|
||||
"tagEditorPageTitle": "Modifier les libellés",
|
||||
"tagEditorPageNewTagFieldLabel": "Nouveau libellé",
|
||||
"tagEditorPageAddTagTooltip": "Ajouter le libellé",
|
||||
"tagEditorSectionRecent": "Ajouts récents",
|
||||
|
||||
"panoramaEnableSensorControl": "Activer le contrôle par capteurs",
|
||||
"panoramaDisableSensorControl": "Désactiver le contrôle par capteurs",
|
||||
|
||||
"sourceViewerPageTitle": "Code source",
|
||||
"@sourceViewerPageTitle": {},
|
||||
|
||||
"filePickerShowHiddenFiles": "Afficher les fichiers masqués",
|
||||
"filePickerDoNotShowHiddenFiles": "Ne pas afficher les fichiers masqués",
|
||||
"filePickerOpenFrom": "Ouvrir à partir de",
|
||||
"filePickerNoItems": "Aucun élément",
|
||||
"filePickerUseThisFolder": "Utiliser ce dossier"
|
||||
}
|
|
@ -22,6 +22,7 @@
|
|||
"showTooltip": "보기",
|
||||
"hideTooltip": "숨기기",
|
||||
"removeTooltip": "제거",
|
||||
"resetButtonTooltip": "복원",
|
||||
|
||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||
|
||||
|
@ -71,6 +72,7 @@
|
|||
"videoActionSettings": "설정",
|
||||
|
||||
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||
"entryInfoActionEditTags": "태그 수정",
|
||||
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||
|
||||
"filterFavouriteLabel": "즐겨찾기",
|
||||
|
@ -186,7 +188,7 @@
|
|||
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||
"removeEntryMetadataDialogMore": "더 보기",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
|
||||
|
||||
"videoSpeedDialogLabel": "재생 배속",
|
||||
|
||||
|
@ -232,6 +234,7 @@
|
|||
"aboutCreditsWorldAtlas1": "이 앱은",
|
||||
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
|
||||
"aboutCreditsTranslators": "번역가:",
|
||||
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||
|
||||
"aboutLicenses": "오픈 소스 라이선스",
|
||||
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
|
||||
|
@ -292,6 +295,7 @@
|
|||
"drawerCollectionFavourites": "즐겨찾기",
|
||||
"drawerCollectionImages": "사진",
|
||||
"drawerCollectionVideos": "동영상",
|
||||
"drawerCollectionAnimated": "애니메이션",
|
||||
"drawerCollectionMotionPhotos": "모션 포토",
|
||||
"drawerCollectionPanoramas": "파노라마",
|
||||
"drawerCollectionRaws": "Raw 이미지",
|
||||
|
@ -372,13 +376,8 @@
|
|||
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
||||
|
||||
"settingsSectionViewer": "뷰어",
|
||||
"settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
|
||||
"settingsViewerShowMinimap": "미니맵 표시",
|
||||
"settingsViewerShowInformation": "상세 정보 표시",
|
||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
|
||||
"settingsViewerUseCutout": "컷아웃 영역 사용",
|
||||
"settingsViewerMaximumBrightness": "최대 밝기",
|
||||
"settingsImageBackground": "이미지 배경",
|
||||
|
||||
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||
|
@ -388,6 +387,15 @@
|
|||
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
|
||||
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
|
||||
|
||||
"settingsViewerOverlayTile": "오버레이",
|
||||
"settingsViewerOverlayTitle": "오버레이",
|
||||
"settingsViewerShowOverlayOnOpening": "열릴 때 표시",
|
||||
"settingsViewerShowMinimap": "미니맵 표시",
|
||||
"settingsViewerShowInformation": "상세 정보 표시",
|
||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||
"settingsViewerEnableOverlayBlurEffect": "흐림 효과",
|
||||
|
||||
"settingsVideoPageTitle": "동영상 설정",
|
||||
"settingsSectionVideo": "동영상",
|
||||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||
|
@ -419,15 +427,15 @@
|
|||
"settingsAllowErrorReporting": "오류 보고서 보내기",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
|
||||
"settingsHiddenFiltersTile": "숨겨진 필터",
|
||||
"settingsHiddenItemsTile": "숨겨진 항목",
|
||||
"settingsHiddenItemsTitle": "숨겨진 항목",
|
||||
|
||||
"settingsHiddenFiltersTitle": "숨겨진 필터",
|
||||
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
|
||||
|
||||
"settingsHiddenPathsTile": "숨겨진 경로",
|
||||
"settingsHiddenPathsTitle": "숨겨진 경로",
|
||||
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
|
||||
"addPathTooltip": "경로 추가",
|
||||
|
||||
"settingsStorageAccessTile": "저장공간 접근",
|
||||
|
@ -496,8 +504,19 @@
|
|||
"viewerInfoSearchSuggestionResolution": "해상도",
|
||||
"viewerInfoSearchSuggestionRights": "권리",
|
||||
|
||||
"tagEditorPageTitle": "태그 수정",
|
||||
"tagEditorPageNewTagFieldLabel": "새 태그",
|
||||
"tagEditorPageAddTagTooltip": "태그 추가",
|
||||
"tagEditorSectionRecent": "최근 이용기록",
|
||||
|
||||
"panoramaEnableSensorControl": "센서 제어 활성화",
|
||||
"panoramaDisableSensorControl": "센서 제어 비활성화",
|
||||
|
||||
"sourceViewerPageTitle": "소스 코드"
|
||||
"sourceViewerPageTitle": "소스 코드",
|
||||
|
||||
"filePickerShowHiddenFiles": "숨겨진 파일 표시",
|
||||
"filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함",
|
||||
"filePickerOpenFrom": "다음에서 열기:",
|
||||
"filePickerNoItems": "항목 없음",
|
||||
"filePickerUseThisFolder": "이 폴더 사용"
|
||||
}
|
||||
|
|
|
@ -186,7 +186,7 @@
|
|||
"removeEntryMetadataDialogTitle": "Удаление метаданных",
|
||||
"removeEntryMetadataDialogMore": "Дополнительно",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?",
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?",
|
||||
|
||||
"videoSpeedDialogLabel": "Скорость воспроизведения",
|
||||
|
||||
|
@ -232,6 +232,7 @@
|
|||
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
|
||||
"aboutCreditsWorldAtlas2": "под лицензией ISC.",
|
||||
"aboutCreditsTranslators": "Переводчики:",
|
||||
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||
|
||||
"aboutLicenses": "Лицензии с открытым исходным кодом",
|
||||
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
|
||||
|
@ -292,6 +293,7 @@
|
|||
"drawerCollectionFavourites": "Избранное",
|
||||
"drawerCollectionImages": "Изображения",
|
||||
"drawerCollectionVideos": "Видео",
|
||||
"drawerCollectionAnimated": "GIF",
|
||||
"drawerCollectionMotionPhotos": "Живые фото",
|
||||
"drawerCollectionPanoramas": "Панорамы",
|
||||
"drawerCollectionRaws": "RAW",
|
||||
|
@ -372,12 +374,6 @@
|
|||
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
|
||||
|
||||
"settingsSectionViewer": "Просмотрщик",
|
||||
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
|
||||
"settingsViewerShowMinimap": "Показать миникарту",
|
||||
"settingsViewerShowInformation": "Показывать информацию",
|
||||
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
|
||||
"settingsViewerShowShootingDetails": "Показать детали съёмки",
|
||||
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
|
||||
"settingsViewerUseCutout": "Использовать область выреза",
|
||||
"settingsImageBackground": "Фон изображения",
|
||||
|
||||
|
@ -388,6 +384,15 @@
|
|||
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
|
||||
"settingsViewerQuickActionEmpty": "Нет кнопок",
|
||||
|
||||
"settingsViewerOverlayTile": "Наложение",
|
||||
"settingsViewerOverlayTitle": "Наложение",
|
||||
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
|
||||
"settingsViewerShowMinimap": "Показать миникарту",
|
||||
"settingsViewerShowInformation": "Показывать информацию",
|
||||
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
|
||||
"settingsViewerShowShootingDetails": "Показать детали съёмки",
|
||||
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
|
||||
|
||||
"settingsVideoPageTitle": "Настройки видео",
|
||||
"settingsSectionVideo": "Видео",
|
||||
"settingsVideoShowVideos": "Показывать видео",
|
||||
|
@ -419,15 +424,15 @@
|
|||
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
|
||||
"settingsSaveSearchHistory": "Сохранять историю поиска",
|
||||
|
||||
"settingsHiddenFiltersTile": "Скрытые фильтры",
|
||||
"settingsHiddenItemsTile": "Скрытые объекты",
|
||||
"settingsHiddenItemsTitle": "Скрытые объекты",
|
||||
|
||||
"settingsHiddenFiltersTitle": "Скрытые фильтры",
|
||||
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
|
||||
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
|
||||
|
||||
"settingsHiddenPathsTile": "Скрытые каталоги",
|
||||
"settingsHiddenPathsTitle": "Скрытые каталоги",
|
||||
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
|
||||
"settingsHiddenPathsEmpty": "Нет скрытых каталогов",
|
||||
"addPathTooltip": "Добавить каталог",
|
||||
|
||||
"settingsStorageAccessTile": "Доступ к хранилищу",
|
||||
|
@ -496,8 +501,16 @@
|
|||
"viewerInfoSearchSuggestionResolution": "Разрешение",
|
||||
"viewerInfoSearchSuggestionRights": "Права",
|
||||
|
||||
"tagEditorSectionRecent": "Недавние",
|
||||
|
||||
"panoramaEnableSensorControl": "Включить сенсорное управление",
|
||||
"panoramaDisableSensorControl": "Отключить сенсорное управление",
|
||||
|
||||
"sourceViewerPageTitle": "Источник"
|
||||
"sourceViewerPageTitle": "Источник",
|
||||
|
||||
"filePickerShowHiddenFiles": "Показывать скрытые файлы",
|
||||
"filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы",
|
||||
"filePickerOpenFrom": "Открыть",
|
||||
"filePickerNoItems": "Ничего нет.",
|
||||
"filePickerUseThisFolder": "Использовать эту папку"
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
|||
enum EntryInfoAction {
|
||||
// general
|
||||
editDate,
|
||||
editTags,
|
||||
removeMetadata,
|
||||
// motion photo
|
||||
viewMotionPhotoVideo,
|
||||
|
@ -13,6 +14,7 @@ enum EntryInfoAction {
|
|||
class EntryInfoActions {
|
||||
static const all = [
|
||||
EntryInfoAction.editDate,
|
||||
EntryInfoAction.editTags,
|
||||
EntryInfoAction.removeMetadata,
|
||||
EntryInfoAction.viewMotionPhotoVideo,
|
||||
];
|
||||
|
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return context.l10n.entryInfoActionEditDate;
|
||||
case EntryInfoAction.editTags:
|
||||
return context.l10n.entryInfoActionEditTags;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return context.l10n.entryInfoActionRemoveMetadata;
|
||||
// motion photo
|
||||
|
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
// general
|
||||
case EntryInfoAction.editDate:
|
||||
return AIcons.date;
|
||||
case EntryInfoAction.editTags:
|
||||
return AIcons.addTag;
|
||||
case EntryInfoAction.removeMetadata:
|
||||
return AIcons.clear;
|
||||
// motion photo
|
||||
|
|
|
@ -26,6 +26,7 @@ enum EntrySetAction {
|
|||
rotateCW,
|
||||
flip,
|
||||
editDate,
|
||||
editTags,
|
||||
removeMetadata,
|
||||
}
|
||||
|
||||
|
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.entryActionFlip;
|
||||
case EntrySetAction.editDate:
|
||||
return context.l10n.entryInfoActionEditDate;
|
||||
case EntrySetAction.editTags:
|
||||
return context.l10n.entryInfoActionEditTags;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return context.l10n.entryInfoActionRemoveMetadata;
|
||||
}
|
||||
|
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.flip;
|
||||
case EntrySetAction.editDate:
|
||||
return AIcons.date;
|
||||
case EntrySetAction.editTags:
|
||||
return AIcons.addTag;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return AIcons.clear;
|
||||
}
|
||||
|
|
18
lib/model/actions/events.dart
Normal file
18
lib/model/actions/events.dart
Normal 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);
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
@ -17,6 +18,8 @@ abstract class AvesAvailability {
|
|||
|
||||
Future<bool> get canLocatePlaces;
|
||||
|
||||
Future<bool> get canUseGoogleMaps;
|
||||
|
||||
Future<bool> get isNewVersionAvailable;
|
||||
}
|
||||
|
||||
|
@ -59,6 +62,9 @@ class LiveAvesAvailability implements AvesAvailability {
|
|||
@override
|
||||
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
|
||||
Future<bool> get isNewVersionAvailable async {
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
|
||||
|
|
43
lib/model/device.dart
Normal file
43
lib/model/device.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -24,6 +24,8 @@ import 'package:country_code/country_code.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
enum EntryDataType { basic, catalog, address, references }
|
||||
|
||||
class AvesEntry {
|
||||
String uri;
|
||||
String? _path, _directory, _filename, _extension;
|
||||
|
@ -235,6 +237,10 @@ class AvesEntry {
|
|||
|
||||
bool get canEdit => path != null;
|
||||
|
||||
bool get canEditDate => canEdit && canEditExif;
|
||||
|
||||
bool get canEditTags => canEdit && canEditXmp;
|
||||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||
|
@ -250,6 +256,30 @@ class AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canEditIptc {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canEditXmp {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.gif:
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.png:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canRemoveMetadata {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
|
@ -394,11 +424,11 @@ class AvesEntry {
|
|||
|
||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||
|
||||
List<String>? _xmpSubjects;
|
||||
Set<String>? _tags;
|
||||
|
||||
List<String> get xmpSubjects {
|
||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
|
||||
return _xmpSubjects!;
|
||||
Set<String> get tags {
|
||||
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
||||
return _tags!;
|
||||
}
|
||||
|
||||
String? _bestTitle;
|
||||
|
@ -408,13 +438,15 @@ class AvesEntry {
|
|||
return _bestTitle;
|
||||
}
|
||||
|
||||
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
||||
int? get catalogDateMillis => _catalogDateMillis;
|
||||
|
||||
set catalogDateMillis(int? dateMillis) {
|
||||
_catalogDateMillis = dateMillis;
|
||||
_bestDate = null;
|
||||
}
|
||||
|
||||
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
||||
|
||||
set catalogMetadata(CatalogMetadata? newMetadata) {
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
|
@ -423,8 +455,8 @@ class AvesEntry {
|
|||
catalogDateMillis = newMetadata?.dateMillis;
|
||||
_catalogMetadata = newMetadata;
|
||||
_bestTitle = null;
|
||||
_xmpSubjects = null;
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
_tags = null;
|
||||
metadataChangeNotifier.notify();
|
||||
|
||||
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
}
|
||||
|
@ -434,7 +466,7 @@ class AvesEntry {
|
|||
addressDetails = null;
|
||||
}
|
||||
|
||||
Future<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 (isSvg) {
|
||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||
|
@ -466,7 +498,7 @@ class AvesEntry {
|
|||
|
||||
set addressDetails(AddressDetails? newAddress) {
|
||||
_addressDetails = newAddress;
|
||||
addressChangeNotifier.notifyListeners();
|
||||
addressChangeNotifier.notify();
|
||||
}
|
||||
|
||||
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
||||
|
@ -590,61 +622,83 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
metadataChangeNotifier.notify();
|
||||
}
|
||||
|
||||
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
|
||||
_catalogMetadata = null;
|
||||
_addressDetails = null;
|
||||
Future<void> refresh({
|
||||
required bool background,
|
||||
required bool persist,
|
||||
required Set<EntryDataType> dataTypes,
|
||||
required Locale geocoderLocale,
|
||||
}) async {
|
||||
// clear derived fields
|
||||
_bestDate = null;
|
||||
_bestTitle = null;
|
||||
_xmpSubjects = null;
|
||||
_tags = null;
|
||||
|
||||
if (persist) {
|
||||
await metadataDb.removeIds({contentId!}, metadataOnly: true);
|
||||
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
|
||||
}
|
||||
|
||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updated != null) {
|
||||
await _applyNewFields(updated.toMap(), persist: persist);
|
||||
await catalog(background: background, persist: persist, force: force);
|
||||
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
|
||||
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updatedEntry != null) {
|
||||
await _applyNewFields(updatedEntry.toMap(), persist: persist);
|
||||
}
|
||||
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
||||
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
||||
}
|
||||
|
||||
Future<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);
|
||||
if (newFields.isEmpty) return false;
|
||||
if (newFields.isEmpty) return {};
|
||||
|
||||
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);
|
||||
if (newFields.isEmpty) return false;
|
||||
if (newFields.isEmpty) return {};
|
||||
|
||||
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) {
|
||||
final _title = bestTitle;
|
||||
if (_title == null) return false;
|
||||
if (_title == null) return {};
|
||||
final date = parseUnknownDateFormat(_title);
|
||||
if (date == null) {
|
||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
||||
return false;
|
||||
return {};
|
||||
}
|
||||
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
||||
}
|
||||
final newFields = await metadataEditService.editDate(this, modifier);
|
||||
return newFields.isNotEmpty;
|
||||
return newFields.isEmpty
|
||||
? {}
|
||||
: {
|
||||
EntryDataType.basic,
|
||||
EntryDataType.catalog,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> removeMetadata(Set<MetadataType> types) async {
|
||||
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
||||
final newFields = await metadataEditService.removeTypes(this, types);
|
||||
return newFields.isNotEmpty;
|
||||
return newFields.isEmpty
|
||||
? {}
|
||||
: {
|
||||
EntryDataType.basic,
|
||||
EntryDataType.catalog,
|
||||
EntryDataType.address,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> delete() {
|
||||
|
@ -665,7 +719,7 @@ class AvesEntry {
|
|||
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||||
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
imageChangeNotifier.notifyListeners();
|
||||
imageChangeNotifier.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
extension ExtraAvesEntryImages on AvesEntry {
|
||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||
|
||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||
|
|
237
lib/model/entry_xmp_iptc.dart
Normal file
237
lib/model/entry_xmp_iptc.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -66,7 +66,9 @@ class AlbumFilter extends CollectionFilter {
|
|||
return PaletteGenerator.fromImageProvider(
|
||||
AppIconImage(packageName: packageName, size: 24),
|
||||
).then((palette) async {
|
||||
final color = palette.dominantColor?.color ?? (await super.color(context));
|
||||
// `dominantColor` is most representative but can have low contrast with a dark background
|
||||
// `vibrantColor` is usually representative and has good contrast with a dark background
|
||||
final color = palette.vibrantColor?.color ?? (await super.color(context));
|
||||
_appColors[album] = color;
|
||||
return color;
|
||||
});
|
||||
|
|
|
@ -31,6 +31,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
|||
];
|
||||
|
||||
static CollectionFilter? fromJson(String jsonString) {
|
||||
if (jsonString.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final jsonMap = jsonDecode(jsonString);
|
||||
if (jsonMap is Map<String, dynamic>) {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -58,15 +59,17 @@ class LocationFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||
final flag = countryCodeToFlag(_countryCode);
|
||||
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
|
||||
// not filled with the shadow color as expected, so we remove them
|
||||
if (flag != null) {
|
||||
return Text(
|
||||
flag,
|
||||
style: TextStyle(fontSize: size, shadows: const []),
|
||||
textScaleFactor: 1.0,
|
||||
);
|
||||
if (_countryCode != null && device.canRenderFlagEmojis) {
|
||||
final flag = countryCodeToFlag(_countryCode);
|
||||
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
|
||||
// not filled with the shadow color as expected, so we remove them
|
||||
if (flag != null) {
|
||||
return Text(
|
||||
flag,
|
||||
style: TextStyle(fontSize: size, shadows: const []),
|
||||
textScaleFactor: 1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
|
||||
}
|
||||
|
|
|
@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
|
|||
|
||||
TagFilter(this.tag) {
|
||||
if (tag.isEmpty) {
|
||||
_test = (entry) => entry.xmpSubjects.isEmpty;
|
||||
_test = (entry) => entry.tags.isEmpty;
|
||||
} else {
|
||||
_test = (entry) => entry.xmpSubjects.contains(tag);
|
||||
_test = (entry) => entry.tags.contains(tag);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@ enum DateEditAction {
|
|||
}
|
||||
|
||||
enum MetadataType {
|
||||
// JPEG COM marker or GIF comment
|
||||
comment,
|
||||
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||
exif,
|
||||
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||
|
@ -23,8 +25,6 @@ enum MetadataType {
|
|||
jfif,
|
||||
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||
jpegAdobe,
|
||||
// JPEG COM marker
|
||||
jpegComment,
|
||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||
jpegDucky,
|
||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
|
@ -42,6 +42,7 @@ class MetadataTypes {
|
|||
static const common = {
|
||||
MetadataType.exif,
|
||||
MetadataType.xmp,
|
||||
MetadataType.comment,
|
||||
MetadataType.iccProfile,
|
||||
MetadataType.iptc,
|
||||
MetadataType.photoshopIrb,
|
||||
|
@ -50,7 +51,6 @@ class MetadataTypes {
|
|||
static const jpeg = {
|
||||
MetadataType.jfif,
|
||||
MetadataType.jpegAdobe,
|
||||
MetadataType.jpegComment,
|
||||
MetadataType.jpegDucky,
|
||||
};
|
||||
}
|
||||
|
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
|
|||
// match `ExifInterface` directory names
|
||||
String getText() {
|
||||
switch (this) {
|
||||
case MetadataType.comment:
|
||||
return 'Comment';
|
||||
case MetadataType.exif:
|
||||
return 'Exif';
|
||||
case MetadataType.iccProfile:
|
||||
|
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
|
|||
return 'JFIF';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'Adobe JPEG';
|
||||
case MetadataType.jpegComment:
|
||||
return 'JpegComment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'Ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
|
|
|
@ -1,20 +1,23 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class OverlayMetadata {
|
||||
final String? aperture, exposureTime, focalLength, iso;
|
||||
@immutable
|
||||
class OverlayMetadata extends Equatable {
|
||||
final double? aperture, focalLength;
|
||||
final String? exposureTime;
|
||||
final int? iso;
|
||||
|
||||
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
||||
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
||||
@override
|
||||
List<Object?> get props => [aperture, exposureTime, focalLength, iso];
|
||||
|
||||
OverlayMetadata({
|
||||
double? aperture,
|
||||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||
|
||||
const OverlayMetadata({
|
||||
this.aperture,
|
||||
this.exposureTime,
|
||||
double? focalLength,
|
||||
int? iso,
|
||||
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
|
||||
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
|
||||
iso = iso != null ? 'ISO$iso' : null;
|
||||
this.focalLength,
|
||||
this.iso,
|
||||
});
|
||||
|
||||
factory OverlayMetadata.fromMap(Map map) {
|
||||
return OverlayMetadata(
|
||||
|
@ -24,9 +27,4 @@ class OverlayMetadata {
|
|||
iso: map['iso'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
|
||||
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
|
||||
|
||||
// entries
|
||||
|
||||
|
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@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;
|
||||
|
||||
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
const where = 'contentId = ?';
|
||||
contentIds.forEach((id) {
|
||||
final whereArgs = [id];
|
||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
||||
if (!metadataOnly) {
|
||||
if (_dataTypes.contains(EntryDataType.basic)) {
|
||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
if (_dataTypes.contains(EntryDataType.catalog)) {
|
||||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
if (_dataTypes.contains(EntryDataType.address)) {
|
||||
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
if (_dataTypes.contains(EntryDataType.references)) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||
|
|
|
@ -4,6 +4,13 @@ import 'package:aves/utils/change_notifier.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Query extends ChangeNotifier {
|
||||
Query({required String? initialValue}) {
|
||||
if (initialValue != null && initialValue.isNotEmpty) {
|
||||
_enabled = true;
|
||||
queryNotifier.value = initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
bool _enabled = false;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/utils/geo_utils.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
@ -16,24 +17,36 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
|||
}
|
||||
}
|
||||
|
||||
static const _separator = ', ';
|
||||
|
||||
String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
|
||||
switch (this) {
|
||||
case CoordinateFormat.dms:
|
||||
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
|
||||
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(_separator);
|
||||
case CoordinateFormat.decimal:
|
||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
return _toDecimal(l10n, latLng).join(_separator);
|
||||
}
|
||||
}
|
||||
|
||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
|
||||
final locale = l10n.localeName;
|
||||
final lat = latLng.latitude;
|
||||
final lng = latLng.longitude;
|
||||
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals);
|
||||
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals);
|
||||
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals, locale);
|
||||
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals, locale);
|
||||
return [
|
||||
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
|
||||
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
|
||||
];
|
||||
}
|
||||
|
||||
static List<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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,6 +65,7 @@ class SettingsDefaults {
|
|||
static const showOverlayShootingDetails = false;
|
||||
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
|
||||
static const viewerUseCutout = true;
|
||||
static const viewerMaxBrightness = false;
|
||||
|
||||
// video
|
||||
static const videoQuickActions = [
|
||||
|
@ -98,4 +99,7 @@ class SettingsDefaults {
|
|||
// accessibility
|
||||
static const accessibilityAnimations = AccessibilityAnimations.system;
|
||||
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
|
||||
|
||||
// file picker
|
||||
static const filePickerShowHiddenFiles = false;
|
||||
}
|
||||
|
|
|
@ -81,6 +81,7 @@ class Settings extends ChangeNotifier {
|
|||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
|
||||
static const viewerUseCutoutKey = 'viewer_use_cutout';
|
||||
static const viewerMaxBrightnessKey = 'viewer_max_brightness';
|
||||
|
||||
// video
|
||||
static const videoQuickActionsKey = 'video_quick_actions';
|
||||
|
@ -116,6 +117,9 @@ class Settings extends ChangeNotifier {
|
|||
// version
|
||||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
||||
|
||||
// file picker
|
||||
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
|
||||
|
||||
// platform settings
|
||||
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
|
||||
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
|
||||
|
@ -152,8 +156,8 @@ class Settings extends ChangeNotifier {
|
|||
enableOverlayBlurEffect = performanceClass >= 29;
|
||||
|
||||
// availability
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
if (hasPlayServices) {
|
||||
final canUseGoogleMaps = await availability.canUseGoogleMaps;
|
||||
if (canUseGoogleMaps) {
|
||||
infoMapStyle = EntryMapStyle.googleNormal;
|
||||
} else {
|
||||
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
|
||||
|
@ -352,6 +356,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
|
||||
|
||||
bool get viewerMaxBrightness => getBoolOrDefault(viewerMaxBrightnessKey, SettingsDefaults.viewerMaxBrightness);
|
||||
|
||||
set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue);
|
||||
|
||||
// video
|
||||
|
||||
List<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);
|
||||
|
||||
// file picker
|
||||
|
||||
bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles);
|
||||
|
||||
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
|
||||
|
||||
// convenience methods
|
||||
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
|
@ -587,10 +601,12 @@ class Settings extends ChangeNotifier {
|
|||
case showOverlayShootingDetailsKey:
|
||||
case enableOverlayBlurEffectKey:
|
||||
case viewerUseCutoutKey:
|
||||
case viewerMaxBrightnessKey:
|
||||
case enableVideoHardwareAccelerationKey:
|
||||
case enableVideoAutoPlayKey:
|
||||
case subtitleShowOutlineKey:
|
||||
case saveSearchHistoryKey:
|
||||
case filePickerShowHiddenFilesKey:
|
||||
if (value is bool) {
|
||||
_prefs!.setBool(key, value);
|
||||
} else {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
|
@ -10,6 +11,7 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/events.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
|
@ -49,7 +51,12 @@ class CollectionLens with ChangeNotifier {
|
|||
final sourceEvents = source.eventBus;
|
||||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(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<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.dart';
|
||||
|
@ -11,6 +12,7 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/events.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
|
@ -104,7 +106,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||
}
|
||||
|
||||
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
|
||||
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
|
||||
entry.catalogDateMillis = _savedDates[entry.contentId];
|
||||
});
|
||||
|
||||
_entryById.addAll(newIdMapEntries);
|
||||
_rawEntries.addAll(entries);
|
||||
|
@ -183,8 +187,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return;
|
||||
}
|
||||
await _moveEntry(entry, newFields, persist: persist);
|
||||
entry.metadataChangeNotifier.notifyListeners();
|
||||
eventBus.fire(EntryMovedEvent({entry}));
|
||||
entry.metadataChangeNotifier.notify();
|
||||
eventBus.fire(EntryMovedEvent(MoveType.move, {entry}));
|
||||
completer.complete(true);
|
||||
},
|
||||
);
|
||||
|
@ -245,6 +249,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
title: newFields['title'] as String?,
|
||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
||||
));
|
||||
} else {
|
||||
debugPrint('failed to find source entry with uri=$sourceUri');
|
||||
}
|
||||
});
|
||||
await metadataDb.saveEntries(movedEntries);
|
||||
|
@ -273,7 +279,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
invalidateAlbumFilterSummary(directories: fromAlbums);
|
||||
_invalidate(movedEntries);
|
||||
eventBus.fire(EntryMovedEvent(movedEntries));
|
||||
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
|
||||
}
|
||||
|
||||
bool get initialized => false;
|
||||
|
@ -284,8 +290,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||
|
||||
Future<void> refreshEntry(AvesEntry entry) async {
|
||||
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
|
||||
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
|
||||
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||
updateDerivedFilters({entry});
|
||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||
}
|
||||
|
@ -381,46 +387,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryAddedEvent {
|
||||
final Set<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});
|
||||
}
|
||||
|
|
48
lib/model/source/events.dart
Normal file
48
lib/model/source/events.dart
Normal 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});
|
||||
}
|
|
@ -67,7 +67,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
// clean up obsolete entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
|
||||
await metadataDb.removeIds(obsoleteContentIds);
|
||||
|
||||
// verify paths because some apps move files without updating their `last modified date`
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||
|
|
|
@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
|
|||
var stopCheckCount = 0;
|
||||
final newMetadata = <CatalogMetadata>{};
|
||||
for (final entry in todo) {
|
||||
await entry.catalog(background: true, persist: true, force: force);
|
||||
await entry.catalog(background: true, force: force, persist: true);
|
||||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= commitCountThreshold) {
|
||||
|
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
|
|||
}
|
||||
|
||||
void updateTags() {
|
||||
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
if (!listEquals(updatedTags, sortedTags)) {
|
||||
sortedTags = List.unmodifiable(updatedTags);
|
||||
invalidateTagFilterSummary();
|
||||
|
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
|
|||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} else {
|
||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
|
||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
|
||||
tags.forEach(_filterEntryCountMap.remove);
|
||||
}
|
||||
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||
|
|
|
@ -81,7 +81,9 @@ class VideoMetadataFormatter {
|
|||
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
|
||||
final mediaInfo = await getVideoMetadata(entry);
|
||||
|
||||
bool isDefined(dynamic value) => value is String && value != '0';
|
||||
// only consider values with at least 8 characters (yyyymmdd),
|
||||
// ignoring unset values like `0`, as well as year values like `2021`
|
||||
bool isDefined(dynamic value) => value is String && value.length >= 8;
|
||||
|
||||
var dateString = mediaInfo[Keys.date];
|
||||
if (!isDefined(dateString)) {
|
||||
|
@ -112,6 +114,7 @@ class VideoMetadataFormatter {
|
|||
|
||||
// `DateTime` does not recognize:
|
||||
// - `UTC 2021-05-30 19:14:21`
|
||||
// - `2021`
|
||||
|
||||
final match = _anotherDatePattern.firstMatch(dateString);
|
||||
if (match != null) {
|
||||
|
@ -371,7 +374,7 @@ class VideoMetadataFormatter {
|
|||
|
||||
static String _formatFilesize(String value) {
|
||||
final size = int.tryParse(value);
|
||||
return size != null ? formatFilesize(size) : value;
|
||||
return size != null ? formatFileSize('en_US', size) : value;
|
||||
}
|
||||
|
||||
static String _formatLanguage(String value) {
|
||||
|
@ -396,20 +399,10 @@ class VideoMetadataFormatter {
|
|||
if (parsed == null) return size;
|
||||
size = parsed;
|
||||
}
|
||||
|
||||
const divider = 1000;
|
||||
|
||||
if (size < divider) return '$size $unit';
|
||||
|
||||
if (size < divider * divider && size % divider == 0) {
|
||||
return '${(size / divider).toStringAsFixed(0)} K$unit';
|
||||
}
|
||||
if (size < divider * divider) {
|
||||
return '${(size / divider).toStringAsFixed(round)} K$unit';
|
||||
}
|
||||
|
||||
if (size < divider * divider * divider && size % divider == 0) {
|
||||
return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit';
|
||||
}
|
||||
if (size < divider * divider) return '${(size / divider).toStringAsFixed(round)} K$unit';
|
||||
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
|
||||
}
|
||||
}
|
||||
|
|
6
lib/ref/iptc.dart
Normal file
6
lib/ref/iptc.dart
Normal file
|
@ -0,0 +1,6 @@
|
|||
class IPTC {
|
||||
static const int applicationRecord = 2;
|
||||
|
||||
// ApplicationRecord tags
|
||||
static const int keywordsTag = 25;
|
||||
}
|
|
@ -8,8 +8,8 @@ import 'package:aves/model/source/media_store_source.dart';
|
|||
import 'package:aves/model/source/source_state.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:fijkplayer/fijkplayer.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class AnalysisService {
|
||||
|
|
|
@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
|
@ -29,8 +28,6 @@ abstract class AndroidAppService {
|
|||
|
||||
Future<bool> shareSingle(String uri, String mimeType);
|
||||
|
||||
Future<bool> canPinToHomeScreen();
|
||||
|
||||
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
|
||||
}
|
||||
|
||||
|
@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService {
|
|||
|
||||
// app shortcuts
|
||||
|
||||
// this ability will not change over the lifetime of the app
|
||||
bool? _canPin;
|
||||
|
||||
@override
|
||||
Future<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
|
||||
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
|
||||
Uint8List? iconBytes;
|
||||
|
@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService {
|
|||
);
|
||||
}
|
||||
try {
|
||||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
await platform.invokeMethod('pinShortcut', <String, dynamic>{
|
||||
'label': label,
|
||||
'iconBytes': iconBytes,
|
||||
'filters': filters?.map((filter) => filter.toJson()).toList(),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||
// adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart`
|
||||
class OutputBuffer extends ByteConversionSinkBase {
|
||||
List<List<int>>? _chunks = <List<int>>[];
|
||||
int _contentLength = 0;
|
||||
|
@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase {
|
|||
return;
|
||||
}
|
||||
_bytes = Uint8List(_contentLength);
|
||||
var offset = 0;
|
||||
for (final chunk in _chunks!) {
|
||||
int offset = 0;
|
||||
for (final List<int> chunk in _chunks!) {
|
||||
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class DeviceService {
|
||||
Future<Map<String, dynamic>> getCapabilities();
|
||||
|
||||
Future<String?> getDefaultTimeZone();
|
||||
|
||||
Future<int> getPerformanceClass();
|
||||
|
@ -10,6 +12,17 @@ abstract class DeviceService {
|
|||
class PlatformDeviceService implements DeviceService {
|
||||
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
|
||||
Future<String?> getDefaultTimeZone() async {
|
||||
try {
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// names should match possible values on platform
|
||||
enum NameConflictStrategy { rename, replace, skip }
|
||||
|
||||
extension ExtraNameConflictStrategy on NameConflictStrategy {
|
||||
String toPlatform() => toString().substring('NameConflictStrategy.'.length);
|
||||
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
|
||||
String toPlatform() => describeEnum(this);
|
||||
|
||||
String getName(BuildContext context) {
|
||||
switch (this) {
|
||||
|
|
|
@ -159,7 +159,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
int? pageId,
|
||||
int? expectedContentLength,
|
||||
BytesReceivedCallback? onBytesReceived,
|
||||
}) {
|
||||
}) async {
|
||||
try {
|
||||
final completer = Completer<Uint8List>.sync();
|
||||
final sink = OutputBuffer();
|
||||
|
@ -191,11 +191,12 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
// `await` here, so that `completeError` will be caught below
|
||||
return await completer.future;
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return Future.sync(() => Uint8List(0));
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -13,6 +14,10 @@ abstract class MetadataEditService {
|
|||
|
||||
Future<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);
|
||||
}
|
||||
|
||||
|
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
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
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||
try {
|
||||
|
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
|
||||
String _toPlatformMetadataType(MetadataType type) {
|
||||
switch (type) {
|
||||
case MetadataType.comment:
|
||||
return 'comment';
|
||||
case MetadataType.exif:
|
||||
return 'exif';
|
||||
case MetadataType.iccProfile:
|
||||
|
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
return 'jfif';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'jpeg_adobe';
|
||||
case MetadataType.jpegComment:
|
||||
return 'jpeg_comment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'jpeg_ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/overlay.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
|
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
|
|||
|
||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||
|
||||
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
|
||||
|
||||
Future<AvesXmp?> getXmp(AvesEntry entry);
|
||||
|
||||
Future<bool> hasContentResolverProp(String prop);
|
||||
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||
|
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
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 = {};
|
||||
|
||||
@override
|
||||
|
|
|
@ -37,8 +37,6 @@ abstract class StorageService {
|
|||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||
|
||||
Future<Uint8List> openFile(String mimeType);
|
||||
|
||||
Future<String?> selectDirectory();
|
||||
}
|
||||
|
||||
class PlatformStorageService implements StorageService {
|
||||
|
@ -174,7 +172,8 @@ class PlatformStorageService implements StorageService {
|
|||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
// `await` here, so that `completeError` will be caught below
|
||||
return await completer.future;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
|
@ -198,7 +197,8 @@ class PlatformStorageService implements StorageService {
|
|||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
// `await` here, so that `completeError` will be caught below
|
||||
return await completer.future;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
|
@ -222,7 +222,8 @@ class PlatformStorageService implements StorageService {
|
|||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
// `await` here, so that `completeError` will be caught below
|
||||
return await completer.future;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
|
@ -249,31 +250,11 @@ class PlatformStorageService implements StorageService {
|
|||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
// `await` here, so that `completeError` will be caught below
|
||||
return await completer.future;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@ class Durations {
|
|||
// info animations
|
||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||
static const tagEditorTransition = Duration(milliseconds: 200);
|
||||
|
||||
// settings animations
|
||||
static const quickActionListAnimation = Duration(milliseconds: 200);
|
||||
|
|
|
@ -14,11 +14,13 @@ class AIcons {
|
|||
static const IconData date = Icons.calendar_today_outlined;
|
||||
static const IconData disc = Icons.fiber_manual_record;
|
||||
static const IconData error = Icons.error_outline;
|
||||
static const IconData folder = Icons.folder_outlined;
|
||||
static const IconData grid = Icons.grid_on_outlined;
|
||||
static const IconData home = Icons.home_outlined;
|
||||
static const IconData language = Icons.translate_outlined;
|
||||
static const IconData location = Icons.place_outlined;
|
||||
static const IconData locationOff = Icons.location_off_outlined;
|
||||
static const IconData mainStorage = Icons.smartphone_outlined;
|
||||
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
||||
static const IconData raw = Icons.raw_on_outlined;
|
||||
static const IconData shooting = Icons.camera_outlined;
|
||||
|
@ -33,6 +35,7 @@ class AIcons {
|
|||
// actions
|
||||
static const IconData add = Icons.add_circle_outline;
|
||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||
static const IconData addTag = MdiIcons.tagPlusOutline;
|
||||
static const IconData replay10 = Icons.replay_10_outlined;
|
||||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||
|
@ -66,6 +69,7 @@ class AIcons {
|
|||
static const IconData print = Icons.print_outlined;
|
||||
static const IconData refresh = Icons.refresh_outlined;
|
||||
static const IconData rename = Icons.title_outlined;
|
||||
static const IconData reset = Icons.restart_alt_outlined;
|
||||
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
||||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
||||
|
|
|
@ -32,10 +32,10 @@ class AndroidFileUtils {
|
|||
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||
avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures');
|
||||
avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures');
|
||||
videoCapturesPaths = {
|
||||
// from Samsung
|
||||
pContext.join(dcimPath, 'Video Captures'),
|
||||
pContext.join(dcimPath, 'Videocaptures'),
|
||||
// from Aves
|
||||
avesVideoCapturesPath,
|
||||
};
|
||||
|
|
|
@ -1,26 +1,9 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin
|
||||
class AChangeNotifier implements Listenable {
|
||||
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
|
||||
|
||||
@override
|
||||
void addListener(VoidCallback listener) => _listeners!.add(listener);
|
||||
|
||||
@override
|
||||
void removeListener(VoidCallback listener) => _listeners!.remove(listener);
|
||||
|
||||
void dispose() => _listeners = null;
|
||||
|
||||
void notifyListeners() {
|
||||
if (_listeners == null) return;
|
||||
final localListeners = List<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');
|
||||
}
|
||||
}
|
||||
// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin
|
||||
class AChangeNotifier extends ChangeNotifier {
|
||||
void notify() {
|
||||
// why is this protected?
|
||||
super.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -132,6 +132,11 @@ class Constants {
|
|||
license: 'Apache 2.0',
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Screen Brightness',
|
||||
license: 'MIT',
|
||||
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Shared Preferences',
|
||||
license: 'BSD 3-Clause',
|
||||
|
|
|
@ -1,38 +1,16 @@
|
|||
String formatFilesize(int size, {int round = 2}) {
|
||||
var divider = 1024;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
if (size < divider) return '$size B';
|
||||
const _kiloDivider = 1024;
|
||||
const _megaDivider = _kiloDivider * _kiloDivider;
|
||||
const _gigaDivider = _megaDivider * _kiloDivider;
|
||||
const _teraDivider = _gigaDivider * _kiloDivider;
|
||||
|
||||
if (size < divider * divider && size % divider == 0) {
|
||||
return '${(size / divider).toStringAsFixed(0)} KB';
|
||||
}
|
||||
if (size < divider * divider) {
|
||||
return '${(size / divider).toStringAsFixed(round)} KB';
|
||||
}
|
||||
String formatFileSize(String locale, int size, {int round = 2}) {
|
||||
if (size < _kiloDivider) return '$size B';
|
||||
|
||||
if (size < divider * divider * divider && size % divider == 0) {
|
||||
return '${(size / (divider * divider)).toStringAsFixed(0)} MB';
|
||||
}
|
||||
if (size < divider * divider * divider) {
|
||||
return '${(size / divider / divider).toStringAsFixed(round)} MB';
|
||||
}
|
||||
|
||||
if (size < divider * divider * divider * divider && size % divider == 0) {
|
||||
return '${(size / (divider * divider * divider)).toStringAsFixed(0)} GB';
|
||||
}
|
||||
if (size < divider * divider * divider * divider) {
|
||||
return '${(size / divider / divider / divider).toStringAsFixed(round)} GB';
|
||||
}
|
||||
|
||||
if (size < divider * divider * divider * divider * divider && size % divider == 0) {
|
||||
return '${(size / divider / divider / divider / divider).toStringAsFixed(0)} TB';
|
||||
}
|
||||
if (size < divider * divider * divider * divider * divider) {
|
||||
return '${(size / divider / divider / divider / divider).toStringAsFixed(round)} TB';
|
||||
}
|
||||
|
||||
if (size < divider * divider * divider * divider * divider * divider && size % divider == 0) {
|
||||
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(0)} PB';
|
||||
}
|
||||
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(round)} PB';
|
||||
final formatter = NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale);
|
||||
if (size < _megaDivider) return '${formatter.format(size / _kiloDivider)} KB';
|
||||
if (size < _gigaDivider) return '${formatter.format(size / _megaDivider)} MB';
|
||||
if (size < _teraDivider) return '${formatter.format(size / _gigaDivider)} GB';
|
||||
return '${formatter.format(size / _teraDivider)} TB';
|
||||
}
|
||||
|
|
|
@ -1,33 +1,23 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class GeoUtils {
|
||||
static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
|
||||
List<int> _split(final double value) {
|
||||
// NumberFormat is necessary to create digit after comma if the value
|
||||
// has no decimal point (only necessary for browser)
|
||||
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
|
||||
return <int>[
|
||||
int.parse(tmp[0]).abs(),
|
||||
int.parse(tmp[1]),
|
||||
];
|
||||
}
|
||||
|
||||
final deg = _split(degDecimal)[0];
|
||||
final minDecimal = (degDecimal.abs() - deg) * 60;
|
||||
final min = _split(minDecimal)[0];
|
||||
static String decimal2sexagesimal(
|
||||
double degDecimal,
|
||||
bool minuteSecondPadding,
|
||||
int secondDecimals,
|
||||
String locale,
|
||||
) {
|
||||
final degAbs = degDecimal.abs();
|
||||
final deg = degAbs.toInt();
|
||||
final minDecimal = (degAbs - deg) * 60;
|
||||
final min = minDecimal.toInt();
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
final secRounded = roundToPrecision(sec, decimals: secondDecimals);
|
||||
var minText = '$min';
|
||||
var secText = secRounded.toStringAsFixed(secondDecimals);
|
||||
if (minuteSecondPadding) {
|
||||
minText = minText.padLeft(2, '0');
|
||||
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
|
||||
}
|
||||
var minText = NumberFormat('0' * (minuteSecondPadding ? 2 : 1), locale).format(min);
|
||||
var secText = NumberFormat('${'0' * (minuteSecondPadding ? 2 : 1)}${secondDecimals > 0 ? '.${'0' * secondDecimals}' : ''}', locale).format(sec);
|
||||
|
||||
return '$deg° $minText′ $secText″';
|
||||
}
|
||||
|
|
|
@ -8,12 +8,13 @@ import 'package:flutter/material.dart';
|
|||
class AboutCredits extends StatelessWidget {
|
||||
const AboutCredits({Key? key}) : super(key: key);
|
||||
|
||||
static const translations = [
|
||||
'Русский: D3ZOXY',
|
||||
];
|
||||
static const translators = {
|
||||
'Русский': 'D3ZOXY',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
|
@ -23,13 +24,13 @@ class AboutCredits extends StatelessWidget {
|
|||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle),
|
||||
child: Text(l10n.aboutCredits, style: Constants.titleTextStyle),
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: context.l10n.aboutCreditsWorldAtlas1),
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas1),
|
||||
const WidgetSpan(
|
||||
child: LinkChip(
|
||||
text: 'World Atlas',
|
||||
|
@ -38,17 +39,19 @@ class AboutCredits extends StatelessWidget {
|
|||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
TextSpan(text: context.l10n.aboutCreditsWorldAtlas2),
|
||||
TextSpan(text: l10n.aboutCreditsWorldAtlas2),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(context.l10n.aboutCreditsTranslators),
|
||||
...translations.map(
|
||||
(line) => Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
|
||||
child: Text(line),
|
||||
),
|
||||
Text(l10n.aboutCreditsTranslators),
|
||||
...translators.entries.map(
|
||||
(kv) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
|
||||
child: Text(l10n.aboutCreditsTranslatorLine(kv.key, kv.value)),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/settings/accessibility_animations.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
|
@ -161,6 +163,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
isRotationLocked: await windowService.isRotationLocked(),
|
||||
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||
);
|
||||
await device.init();
|
||||
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||
|
||||
// keep screen on
|
||||
|
|
|
@ -10,7 +10,6 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||
|
@ -46,7 +45,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||
final FocusNode _queryBarFocusNode = FocusNode();
|
||||
late final Listenable _queryFocusRequestNotifier;
|
||||
|
@ -69,7 +67,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
vsync: this,
|
||||
);
|
||||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
||||
}
|
||||
|
@ -104,53 +101,46 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return FutureBuilder<bool>(
|
||||
future: _canAddShortcutsLoader,
|
||||
builder: (context, snapshot) {
|
||||
final canAddShortcuts = snapshot.data ?? false;
|
||||
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
|
||||
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
|
||||
builder: (context, s, child) {
|
||||
final isSelecting = s.item1;
|
||||
final selectedItemCount = s.item2;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(
|
||||
isSelecting: isSelecting,
|
||||
selectedItemCount: selectedItemCount,
|
||||
supportShortcuts: canAddShortcuts,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showFilterBar)
|
||||
FilterBar(
|
||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
),
|
||||
if (queryEnabled)
|
||||
EntryQueryBar(
|
||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
focusNode: _queryBarFocusNode,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
|
||||
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
|
||||
builder: (context, s, child) {
|
||||
final isSelecting = s.item1;
|
||||
final selectedItemCount = s.item2;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(
|
||||
isSelecting: isSelecting,
|
||||
selectedItemCount: selectedItemCount,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showFilterBar)
|
||||
FilterBar(
|
||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
),
|
||||
if (queryEnabled)
|
||||
EntryQueryBar(
|
||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
focusNode: _queryBarFocusNode,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -214,14 +204,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
List<Widget> _buildActions({
|
||||
required bool isSelecting,
|
||||
required int selectedItemCount,
|
||||
required bool supportShortcuts,
|
||||
}) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
|
||||
action,
|
||||
appMode: appMode,
|
||||
isSelecting: isSelecting,
|
||||
supportShortcuts: supportShortcuts,
|
||||
sortFactor: collection.sortFactor,
|
||||
itemCount: collection.entryCount,
|
||||
selectedItemCount: selectedItemCount,
|
||||
|
@ -269,6 +257,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||
...[
|
||||
EntrySetAction.editDate,
|
||||
EntrySetAction.editTags,
|
||||
EntrySetAction.removeMetadata,
|
||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||
],
|
||||
|
@ -295,7 +284,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
|
||||
Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}');
|
||||
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
|
||||
Key _getActionKey(EntrySetAction action) => Key('menu-${describeEnum(action)}');
|
||||
|
||||
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
|
||||
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
||||
|
@ -439,6 +429,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
_actionDelegate.onActionSelected(context, action);
|
||||
break;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||
|
@ -8,6 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
|||
import 'package:aves/widgets/common/providers/query_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -37,10 +39,12 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
||||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: QueryProvider(
|
||||
initialQuery: liveFilter?.query,
|
||||
child: Builder(
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () {
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -48,7 +49,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
|||
];
|
||||
case EntrySortFactor.size:
|
||||
return [
|
||||
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0),
|
||||
if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),
|
||||
];
|
||||
}
|
||||
},
|
||||
|
|
|
@ -4,7 +4,9 @@ import 'dart:io';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
|
@ -43,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
EntrySetAction action, {
|
||||
required AppMode appMode,
|
||||
required bool isSelecting,
|
||||
required bool supportShortcuts,
|
||||
required EntrySortFactor sortFactor,
|
||||
required int itemCount,
|
||||
required int selectedItemCount,
|
||||
|
@ -66,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.toggleTitleSearch:
|
||||
return !isSelecting;
|
||||
case EntrySetAction.addShortcut:
|
||||
return appMode == AppMode.main && !isSelecting && supportShortcuts;
|
||||
return appMode == AppMode.main && !isSelecting && device.canPinShortcut;
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
|
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return appMode == AppMode.main && isSelecting;
|
||||
}
|
||||
|
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.editTags:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return hasSelection;
|
||||
}
|
||||
|
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
case EntrySetAction.editDate:
|
||||
_editDate(context);
|
||||
break;
|
||||
case EntrySetAction.editTags:
|
||||
_editTags(context);
|
||||
break;
|
||||
case EntrySetAction.removeMetadata:
|
||||
_removeMetadata(context);
|
||||
break;
|
||||
|
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
BuildContext context,
|
||||
Selection<AvesEntry> selection,
|
||||
Set<AvesEntry> todoItems,
|
||||
Future<bool> Function(AvesEntry entry) op,
|
||||
Future<Set<EntryDataType>> Function(AvesEntry entry) op,
|
||||
) async {
|
||||
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = todoItems.length;
|
||||
|
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||
final success = await op(entry);
|
||||
return ImageOpEvent(success: success, uri: entry.uri);
|
||||
final dataTypes = await op(entry);
|
||||
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
|
||||
}).asBroadcastStream(),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
|
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
);
|
||||
if (confirmed == null || !confirmed) return null;
|
||||
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation);
|
||||
return supported;
|
||||
}
|
||||
|
||||
|
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif);
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
|
||||
if (todoItems == null || todoItems.isEmpty) return;
|
||||
|
||||
final modifier = await selectDateModifier(context, todoItems);
|
||||
|
@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
@ -596,6 +626,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
|||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters));
|
||||
await androidAppService.pinToHomeScreen(name, coverEntry, filters: filters);
|
||||
if (!device.showPinShortcutFeedback) {
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,8 +65,8 @@ class MonthSectionHeader<T> extends StatelessWidget {
|
|||
if (date == null) return l10n.sectionUnknown;
|
||||
if (date.isThisMonth) return l10n.dateThisMonth;
|
||||
final locale = l10n.localeName;
|
||||
if (date.isThisYear) return DateFormat.MMMM(locale).format(date);
|
||||
return DateFormat.yMMMM(locale).format(date);
|
||||
final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date);
|
||||
return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}';
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -42,8 +42,6 @@ class _EntryQueryBarState extends State<EntryQueryBar> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
// TODO TLAD focus on text field when enabled (`autofocus` is unusable)
|
||||
// TODO TLAD lose focus on navigation to viewer?
|
||||
void _registerWidget(EntryQueryBar widget) {
|
||||
widget.queryNotifier.addListener(_onQueryChanged);
|
||||
}
|
||||
|
|
|
@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
|
|||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
mixin EntryEditorMixin {
|
||||
|
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
|
|||
return modifier;
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
|
|
|
@ -75,13 +75,15 @@ mixin SizeAwareMixin {
|
|||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final neededSize = formatFilesize(needed);
|
||||
final freeSize = formatFilesize(free);
|
||||
final l10n = context.l10n;
|
||||
final locale = l10n.localeName;
|
||||
final neededSize = formatFileSize(locale, needed);
|
||||
final freeSize = formatFileSize(locale, free);
|
||||
final volume = destinationVolume.getDescription(context);
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
title: context.l10n.notEnoughSpaceDialogTitle,
|
||||
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
|
||||
title: l10n.notEnoughSpaceDialogTitle,
|
||||
content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/events.dart';
|
||||
import 'package:aves/model/source/source_state.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
|
|
@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight, Node;
|
||||
|
||||
// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6
|
||||
// adapted from package `flutter_highlight` v0.7.0 `HighlightView`
|
||||
// TODO TLAD use the TextSpan getter when this is fixed: https://github.com/git-touch/highlight/issues/6
|
||||
|
||||
/// Highlight Flutter Widget
|
||||
class AvesHighlightView extends StatelessWidget {
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/*
|
||||
This is derived from `draggable_scrollbar` package v0.0.4:
|
||||
adapted from package `draggable_scrollbar` v0.0.4:
|
||||
- removed default thumb builders
|
||||
- allow any `ScrollView` as child
|
||||
- allow any `Widget` as label content
|
||||
|
|
395
lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart
Normal file
395
lib/widgets/common/behaviour/eager_scale_gesture_recognizer.dart
Normal 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';
|
||||
}
|
|
@ -9,16 +9,20 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
final String? title;
|
||||
final Iterable<CollectionFilter> filters;
|
||||
final ValueNotifier<String?> expandedNotifier;
|
||||
final bool showGenericIcon;
|
||||
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
||||
const ExpandableFilterRow({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.filters,
|
||||
required this.expandedNotifier,
|
||||
this.showGenericIcon = true,
|
||||
this.heroTypeBuilder,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
static const double horizontalPadding = 8;
|
||||
|
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
|
|||
// key `album-{path}` is expected by test driver
|
||||
key: Key(filter.key),
|
||||
filter: filter,
|
||||
showGenericIcon: showGenericIcon,
|
||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import 'dart:ui' as ui;
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
|
||||
// adapted from Flutter `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
|
||||
// to transition between 2 different fits during hero animation:
|
||||
// - BoxFit.cover at t=0
|
||||
// - BoxFit.contain at t=1
|
||||
|
@ -190,7 +190,8 @@ class _TransitionImagePainter extends CustomPainter {
|
|||
Offset.zero & inputSize,
|
||||
);
|
||||
if (background != null) {
|
||||
canvas.drawRect(destinationRect, Paint()..color = background!);
|
||||
// deflate to avoid background artifact around opaque image
|
||||
canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!);
|
||||
}
|
||||
canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import 'package:provider/provider.dart';
|
|||
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||
// cf https://github.com/flutter/flutter/issues/49027
|
||||
// adapted from `RenderSliverFixedExtentBoxAdaptor`
|
||||
// adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart`
|
||||
class SectionedListSliver<T> extends StatelessWidget {
|
||||
const SectionedListSliver({Key? key}) : super(key: key);
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import 'package:provider/provider.dart';
|
|||
typedef FilterCallback = void Function(CollectionFilter filter);
|
||||
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
|
||||
|
||||
enum HeroType { always, onTap }
|
||||
enum HeroType { always, onTap, never }
|
||||
|
||||
@immutable
|
||||
class AvesFilterDecoration {
|
||||
|
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final bool removable, showGenericIcon, useFilterColor;
|
||||
final AvesFilterDecoration? decoration;
|
||||
final String? banner;
|
||||
final Widget? details;
|
||||
final Widget? leadingOverride, details;
|
||||
final double padding, maxWidth;
|
||||
final HeroType heroType;
|
||||
final FilterCallback? onTap;
|
||||
|
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.useFilterColor = true,
|
||||
this.decoration,
|
||||
this.banner,
|
||||
this.leadingOverride,
|
||||
this.details,
|
||||
this.padding = 6.0,
|
||||
this.maxWidth = defaultMaxChipWidth,
|
||||
|
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
|
||||
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
||||
final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
||||
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
||||
|
||||
final decoration = widget.decoration;
|
||||
|
|
|
@ -18,13 +18,14 @@ class MagnifierController {
|
|||
late ScaleStateChange _currentScaleState, previousScaleState;
|
||||
|
||||
MagnifierController({
|
||||
Offset initialPosition = Offset.zero,
|
||||
MagnifierState? initialState,
|
||||
}) : super() {
|
||||
initial = MagnifierState(
|
||||
position: initialPosition,
|
||||
scale: null,
|
||||
source: ChangeSource.internal,
|
||||
);
|
||||
initial = initialState ??
|
||||
const MagnifierState(
|
||||
position: Offset.zero,
|
||||
scale: null,
|
||||
source: ChangeSource.internal,
|
||||
);
|
||||
previousState = initial;
|
||||
_currentState = initial;
|
||||
_setState(initial);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue