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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v1.5.7] - 2021-12-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- add and remove tags to JPEG/GIF/PNG/TIFF images
|
||||||
|
- French translation
|
||||||
|
- support for Android KitKat (without Google Maps)
|
||||||
|
- Viewer: maximum brightness option
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Settings: select hidden path directory with a custom file picker instead of the native SAF one
|
||||||
|
- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- pinch-to-zoom gesture on thumbnails was difficult to trigger
|
||||||
|
- double-tap gesture in the viewer was ignored in some cases
|
||||||
|
- copied items had the wrong date
|
||||||
|
|
||||||
## [v1.5.6] - 2021-11-12
|
## [v1.5.6] - 2021-11-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -29,7 +29,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
|
||||||
|
|
||||||
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||||
|
|
||||||
Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
|
Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ At this stage this project does *not* accept PRs, except for translations.
|
||||||
|
|
||||||
### Translations
|
### Translations
|
||||||
|
|
||||||
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled.
|
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French are already handled.
|
||||||
|
|
||||||
### Donations
|
### Donations
|
||||||
|
|
||||||
|
@ -82,5 +82,10 @@ To run the app:
|
||||||
# flutter run -t lib/main_play.dart --flavor play
|
# flutter run -t lib/main_play.dart --flavor play
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To run the app on API 19 emulators:
|
||||||
|
```
|
||||||
|
# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering
|
||||||
|
```
|
||||||
|
|
||||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||||
|
|
|
@ -55,9 +55,8 @@ android {
|
||||||
applicationId appId
|
applicationId appId
|
||||||
// minSdkVersion constraints:
|
// minSdkVersion constraints:
|
||||||
// - Flutter & other plugins: 16
|
// - Flutter & other plugins: 16
|
||||||
// - google_maps_flutter v2.0.5: 20
|
// - google_maps_flutter v2.1.1: 20
|
||||||
// - Aves native: 19
|
minSdkVersion 19
|
||||||
minSdkVersion 20
|
|
||||||
targetSdkVersion 31
|
targetSdkVersion 31
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
@ -149,7 +148,7 @@ dependencies {
|
||||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.3.0'
|
kapt 'androidx.annotation:annotation:1.3.0'
|
||||||
|
|
|
@ -4,21 +4,12 @@
|
||||||
android:installLocation="auto">
|
android:installLocation="auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Scoped storage for primary storage is unusable on Android Q,
|
Scoped storage on Android Q is inconvenient because users need to confirm edition on each individual file.
|
||||||
because users are required to confirm each file to be edited or deleted.
|
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
|
||||||
These items can only be deleted one by one after catching
|
|
||||||
a `RecoverableSecurityException` and requesting permission for each.
|
|
||||||
|
|
||||||
Android R improvements:
|
|
||||||
- bulk changes (e.g. `createDeleteRequest`):
|
|
||||||
https://developer.android.com/preview/privacy/storage#media-file-access
|
|
||||||
- raw path access:
|
|
||||||
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29"
|
android:maxSdkVersion="29"
|
||||||
|
@ -34,6 +25,9 @@
|
||||||
<!-- for API < 26 -->
|
<!-- for API < 26 -->
|
||||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
|
|
||||||
|
<!-- allow install on API 19, but Google Maps is from API 20 -->
|
||||||
|
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
|
||||||
|
|
||||||
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
|
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|
|
@ -23,7 +23,6 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
||||||
private var backgroundFlutterEngine: FlutterEngine? = null
|
private var backgroundFlutterEngine: FlutterEngine? = null
|
||||||
|
@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
||||||
|
|
||||||
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
|
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
|
||||||
// channels for analysis
|
// channels for analysis
|
||||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||||
|
@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
||||||
getString(R.string.analysis_notification_action_stop),
|
getString(R.string.analysis_notification_action_stop),
|
||||||
stopServiceIntent
|
stopServiceIntent
|
||||||
).build()
|
).build()
|
||||||
|
val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
|
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
|
||||||
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
|
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
|
||||||
.setContentText(message)
|
.setContentText(message)
|
||||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
|
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(icon)
|
||||||
.setContentIntent(openAppIntent)
|
.setContentIntent(openAppIntent)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.addAction(stopAction)
|
.addAction(stopAction)
|
||||||
|
|
|
@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
|
||||||
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
|
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
|
||||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||||
|
@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
|
||||||
DELETE_SINGLE_PERMISSION_REQUEST,
|
DELETE_SINGLE_PERMISSION_REQUEST,
|
||||||
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
|
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
|
||||||
CREATE_FILE_REQUEST,
|
CREATE_FILE_REQUEST,
|
||||||
OPEN_FILE_REQUEST,
|
OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||||
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// save access permissions across reboots
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||||
val takeFlags = (data.flags
|
// save access permissions across reboots
|
||||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
val takeFlags = (data.flags
|
||||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||||
|
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||||
|
}
|
||||||
|
|
||||||
// resume pending action
|
// resume pending action
|
||||||
onStorageAccessResult(requestCode, treeUri)
|
onStorageAccessResult(requestCode, treeUri)
|
||||||
|
@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() {
|
||||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_MAIN -> {
|
Intent.ACTION_MAIN -> {
|
||||||
intent.getStringExtra("page")?.let { page ->
|
intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
|
||||||
var filters = intent.getStringArrayExtra("filters")?.toList()
|
var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
|
||||||
if (filters == null) {
|
if (filters == null) {
|
||||||
// fallback for shortcuts created on API < 26
|
// fallback for shortcuts created on API < 26
|
||||||
val filterString = intent.getStringExtra("filtersString")
|
val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
|
||||||
if (filterString != null) {
|
if (filterString != null) {
|
||||||
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"page" to page,
|
INTENT_DATA_KEY_PAGE to page,
|
||||||
"filters" to filters,
|
INTENT_DATA_KEY_FILTERS to filters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
|
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
|
||||||
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
|
(intent.data ?: (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to "view",
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||||
"uri" to uri.toString(),
|
INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional
|
||||||
"mimeType" to intent.type, // MIME type is optional
|
INTENT_DATA_KEY_URI to uri.toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
|
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to "pick",
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
|
||||||
"mimeType" to intent.type,
|
INTENT_DATA_KEY_MIME_TYPE to intent.type,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Intent.ACTION_SEARCH -> {
|
Intent.ACTION_SEARCH -> {
|
||||||
val viewUri = intent.dataString
|
val viewUri = intent.dataString
|
||||||
return if (viewUri != null) hashMapOf(
|
return if (viewUri != null) hashMapOf(
|
||||||
"action" to "view",
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||||
"uri" to viewUri,
|
INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
|
||||||
"mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
|
INTENT_DATA_KEY_URI to viewUri,
|
||||||
) else hashMapOf(
|
) else hashMapOf(
|
||||||
"action" to "search",
|
INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
|
||||||
"query" to intent.getStringExtra(SearchManager.QUERY),
|
INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Intent.ACTION_RUN -> {
|
Intent.ACTION_RUN -> {
|
||||||
|
@ -261,7 +262,7 @@ class MainActivity : FlutterActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
.putExtra("page", "/search")
|
.putExtra(SHORTCUT_KEY_PAGE, "/search")
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@ -270,7 +271,7 @@ class MainActivity : FlutterActivity() {
|
||||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||||
.setIntent(
|
.setIntent(
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
.putExtra("page", "/collection")
|
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
|
||||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() {
|
||||||
const val OPEN_FROM_ANALYSIS_SERVICE = 2
|
const val OPEN_FROM_ANALYSIS_SERVICE = 2
|
||||||
const val CREATE_FILE_REQUEST = 3
|
const val CREATE_FILE_REQUEST = 3
|
||||||
const val OPEN_FILE_REQUEST = 4
|
const val OPEN_FILE_REQUEST = 4
|
||||||
const val SELECT_DIRECTORY_REQUEST = 5
|
const val DELETE_SINGLE_PERMISSION_REQUEST = 5
|
||||||
const val DELETE_SINGLE_PERMISSION_REQUEST = 6
|
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
|
||||||
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
|
|
||||||
|
const val INTENT_DATA_KEY_ACTION = "action"
|
||||||
|
const val INTENT_DATA_KEY_FILTERS = "filters"
|
||||||
|
const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
|
||||||
|
const val INTENT_DATA_KEY_PAGE = "page"
|
||||||
|
const val INTENT_DATA_KEY_URI = "uri"
|
||||||
|
const val INTENT_DATA_KEY_QUERY = "query"
|
||||||
|
|
||||||
|
const val INTENT_ACTION_PICK = "pick"
|
||||||
|
const val INTENT_ACTION_SEARCH = "search"
|
||||||
|
const val INTENT_ACTION_VIEW = "view"
|
||||||
|
|
||||||
|
const val SHORTCUT_KEY_PAGE = "page"
|
||||||
|
const val SHORTCUT_KEY_FILTERS_ARRAY = "filters"
|
||||||
|
const val SHORTCUT_KEY_FILTERS_STRING = "filtersString"
|
||||||
|
|
||||||
// request code to pending runnable
|
// request code to pending runnable
|
||||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||||
|
|
|
@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
|
||||||
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
var removed = false
|
var removed = false
|
||||||
try {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
|
try {
|
||||||
} catch (e: Exception) {
|
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
|
||||||
Log.w(LOG_TAG, "failed to get settings", e)
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.success(removed)
|
result.success(removed)
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// can be null or empty
|
// can be null or empty
|
||||||
val contentIds = call.argument<List<Int>>("contentIds");
|
val contentIds = call.argument<List<Int>>("contentIds")
|
||||||
|
|
||||||
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
|
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
|
||||||
val intent = Intent(activity, AnalysisService::class.java)
|
val intent = Intent(activity, AnalysisService::class.java)
|
||||||
|
|
|
@ -18,6 +18,10 @@ import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING
|
||||||
|
import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE
|
||||||
import deckers.thibault.aves.R
|
import deckers.thibault.aves.R
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
|
@ -47,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
"openMap" -> safe(call, result, ::openMap)
|
"openMap" -> safe(call, result, ::openMap)
|
||||||
"setAs" -> safe(call, result, ::setAs)
|
"setAs" -> safe(call, result, ::setAs)
|
||||||
"share" -> safe(call, result, ::share)
|
"share" -> safe(call, result, ::share)
|
||||||
"canPin" -> safe(call, result, ::canPin)
|
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
|
||||||
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
fun addPackageDetails(intent: Intent) {
|
fun addPackageDetails(intent: Intent) {
|
||||||
// apps tend to use their name in English when creating directories
|
// apps tend to use their name in English when creating directories
|
||||||
// so we get their names in English as well as the current locale
|
// so we get their names in English as well as the current locale
|
||||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
val englishConfig = Configuration().apply {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
setLocale(Locale.ENGLISH)
|
||||||
|
} else {
|
||||||
|
@Suppress("deprecation")
|
||||||
|
locale = Locale.ENGLISH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
||||||
|
@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
// shortcuts
|
// shortcuts
|
||||||
|
|
||||||
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
||||||
private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
result.success(isPinSupported())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pin(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
|
@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPinSupported()) {
|
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||||
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
|
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
val intent = when {
|
val intent = when {
|
||||||
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
|
||||||
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||||
.putExtra("page", "/collection")
|
.putExtra(SHORTCUT_KEY_PAGE, "/collection")
|
||||||
.putExtra("filters", filters.toTypedArray())
|
.putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
|
||||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||||
// so we use a joined `String` as fallback
|
// so we use a joined `String` as fallback
|
||||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
.putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
|
||||||
else -> {
|
else -> {
|
||||||
result.error("pin-intent", "failed to build intent", null)
|
result.error("pin-intent", "failed to build intent", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,21 +1,42 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class DeviceHandler : MethodCallHandler {
|
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"getCapabilities" -> safe(call, result, ::getCapabilities)
|
||||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val sdkInt = Build.VERSION.SDK_INT
|
||||||
|
result.success(
|
||||||
|
hashMapOf(
|
||||||
|
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||||
|
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
|
||||||
|
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
||||||
|
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||||
|
// as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
|
||||||
|
// but using hybrid composition would make it usable on API 19 too,
|
||||||
|
// cf https://github.com/flutter/flutter/issues/23728
|
||||||
|
"canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH),
|
||||||
|
"hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT),
|
||||||
|
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(TimeZone.getDefault().id)
|
result.success(TimeZone.getDefault().id)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||||
|
"setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
|
||||||
|
"setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
|
||||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val iptc = call.argument<List<FieldMap>>("iptc")
|
||||||
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
val postEditScan = call.argument<Boolean>("postEditScan")
|
||||||
|
if (entryMap == null || postEditScan == null) {
|
||||||
|
result.error("setIptc-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
|
val path = entryMap["path"] as String?
|
||||||
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
result.error("setIptc-args", "failed because entry fields are missing", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getProvider(uri)
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val xmp = call.argument<String>("xmp")
|
||||||
|
val extendedXmp = call.argument<String>("extendedXmp")
|
||||||
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
if (entryMap == null) {
|
||||||
|
result.error("setXmp-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
|
val path = entryMap["path"] as String?
|
||||||
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
result.error("setXmp-args", "failed because entry fields are missing", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = getProvider(uri)
|
||||||
|
if (provider == null) {
|
||||||
|
result.error("setXmp-provider", "failed to find provider for uri=$uri", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val types = call.argument<List<String>>("types")
|
val types = call.argument<List<String>>("types")
|
||||||
val entryMap = call.argument<FieldMap>("entry")
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
|
|
|
@ -10,6 +10,8 @@ import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
|
import com.adobe.internal.xmp.XMPMetaFactory
|
||||||
|
import com.adobe.internal.xmp.options.SerializeOptions
|
||||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.lang.KeyValuePair
|
import com.drew.lang.KeyValuePair
|
||||||
|
@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||||
|
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
|
||||||
|
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
|
||||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
val kv = pair as KeyValuePair
|
val kv = pair as KeyValuePair
|
||||||
val key = kv.key
|
val key = kv.key
|
||||||
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
|
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
|
||||||
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset
|
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
} else {
|
||||||
|
Charset.forName("UTF-8")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kv.value.charset
|
||||||
|
}
|
||||||
val valueString = String(kv.value.bytes, charset)
|
val valueString = String(kv.value.bytes, charset)
|
||||||
val dirs = extractPngProfile(key, valueString)
|
val dirs = extractPngProfile(key, valueString)
|
||||||
if (dirs?.any() == true) {
|
if (dirs?.any() == true) {
|
||||||
|
@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||||
try {
|
try {
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
|
||||||
|
}
|
||||||
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
|
||||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
|
||||||
}
|
}
|
||||||
|
@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val saveExposureTime: (value: Rational) -> Unit = {
|
val saveExposureTime = fun(value: Rational) {
|
||||||
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
|
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
|
||||||
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
|
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
|
||||||
// and process it to make sure the numerator is `1` when the ratio value is less than 1
|
// and process it to make sure the numerator is `1` when the ratio value is less than 1
|
||||||
val num = it.numerator
|
val num = value.numerator
|
||||||
val denom = it.denominator
|
val denom = value.denominator
|
||||||
metadataMap[KEY_EXPOSURE_TIME] = when {
|
metadataMap[KEY_EXPOSURE_TIME] = when {
|
||||||
num >= denom -> "${it.toSimpleString(true)}″"
|
num >= denom -> "${value.toSimpleString(true)}″"
|
||||||
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
|
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
|
||||||
else -> it.toString()
|
else -> value.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -734,6 +749,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
|
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
if (mimeType == null || uri == null) {
|
||||||
|
result.error("getIptc-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MimeTypes.canReadWithPixyMeta(mimeType)) {
|
||||||
|
try {
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
val iptcDataList = PixyMetaHelper.getIptc(input)
|
||||||
|
result.success(iptcDataList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
if (mimeType == null || uri == null) {
|
||||||
|
result.error("getXmp-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canReadWithMetadataExtractor(mimeType)) {
|
||||||
|
try {
|
||||||
|
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||||
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
|
val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
|
||||||
|
result.success(xmpStrings.toMutableList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
} catch (e: NoClassDefFoundError) {
|
||||||
|
result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val prop = call.argument<String>("prop")
|
val prop = call.argument<String>("prop")
|
||||||
if (prop == null) {
|
if (prop == null) {
|
||||||
|
@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
"XMP",
|
"XMP",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val xmpSerializeOptions = SerializeOptions().apply {
|
||||||
|
omitPacketWrapper = true // e.g. <?xpacket begin="..." id="W5M0MpCehiHzreSzNTczkc9d"?>...<?xpacket end="r"?>
|
||||||
|
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
|
||||||
|
}
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||||
|
|
|
@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
|
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get settings", e)
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
}
|
}
|
||||||
result.success(locked)
|
result.success(locked)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
@ -68,7 +69,12 @@ class RegionFetcher internal constructor(
|
||||||
if (currentDecoderRef == null) {
|
if (currentDecoderRef == null) {
|
||||||
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
BitmapRegionDecoder.newInstance(input, false)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
BitmapRegionDecoder.newInstance(input)
|
||||||
|
} else {
|
||||||
|
@Suppress("deprecation")
|
||||||
|
BitmapRegionDecoder.newInstance(input, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (newDecoder == null) {
|
if (newDecoder == null) {
|
||||||
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
|
|
||||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
if (update()) {
|
if (update()) {
|
||||||
success(
|
val settings: FieldMap = hashMapOf(
|
||||||
hashMapOf(
|
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
|
||||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
|
||||||
|
}
|
||||||
|
success(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,14 +51,15 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
||||||
accelerometerRotation = newAccelerometerRotation
|
accelerometerRotation = newAccelerometerRotation
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
if (transitionAnimationScale != newTransitionAnimationScale) {
|
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
|
||||||
transitionAnimationScale = newTransitionAnimationScale
|
if (transitionAnimationScale != newTransitionAnimationScale) {
|
||||||
changed = true
|
transitionAnimationScale = newTransitionAnimationScale
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(LOG_TAG, "failed to get settings", e)
|
Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.EventChannel.EventSink
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
|
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
|
||||||
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||||
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
|
|
||||||
else -> endOfStream()
|
else -> endOfStream()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFile() {
|
private fun createFile() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||||
|
// TODO TLAD [<=API18] create file
|
||||||
|
error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val name = args["name"] as String?
|
val name = args["name"] as String?
|
||||||
val mimeType = args["mimeType"] as String?
|
val mimeType = args["mimeType"] as String?
|
||||||
val bytes = args["bytes"] as ByteArray?
|
val bytes = args["bytes"] as ByteArray?
|
||||||
|
@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
|
|
||||||
|
|
||||||
private fun openFile() {
|
private fun openFile() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||||
|
// TODO TLAD [<=API18] open file
|
||||||
|
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val mimeType = args["mimeType"] as String?
|
val mimeType = args["mimeType"] as String?
|
||||||
if (mimeType == null) {
|
if (mimeType == null) {
|
||||||
error("openFile-args", "failed because of missing arguments", null)
|
error("openFile-args", "failed because of missing arguments", null)
|
||||||
|
@ -158,24 +168,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
||||||
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
|
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectDirectory() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
|
||||||
|
|
||||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
|
||||||
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
|
|
||||||
endOfStream()
|
|
||||||
}, {
|
|
||||||
success(null)
|
|
||||||
endOfStream()
|
|
||||||
})
|
|
||||||
activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
|
|
||||||
} else {
|
|
||||||
success(null)
|
|
||||||
endOfStream()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {}
|
override fun onCancel(arguments: Any?) {}
|
||||||
|
|
||||||
private fun success(result: Any?) {
|
private fun success(result: Any?) {
|
||||||
|
|
|
@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
|
||||||
val bitmapHeight: Int
|
val bitmapHeight: Int
|
||||||
if (width / height > svgWidth / svgHeight) {
|
if (width / height > svgWidth / svgHeight) {
|
||||||
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
|
||||||
bitmapHeight = height;
|
bitmapHeight = height
|
||||||
} else {
|
} else {
|
||||||
bitmapWidth = width
|
bitmapWidth = width
|
||||||
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
|
||||||
|
|
|
@ -223,7 +223,9 @@ object ExifInterfaceHelper {
|
||||||
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
|
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
|
||||||
|
|
||||||
// exclude Exif directory when it only includes image size
|
// exclude Exif directory when it only includes image size
|
||||||
val isUselessExif: (Map<String, String>) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") }
|
val isUselessExif = fun(it: Map<String, String>): Boolean {
|
||||||
|
return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width")
|
||||||
|
}
|
||||||
|
|
||||||
return HashMap<String, Map<String, String>>().apply {
|
return HashMap<String, Map<String, String>>().apply {
|
||||||
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
|
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
|
||||||
|
|
|
@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
|
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
|
||||||
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
|
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
|
||||||
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
||||||
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
||||||
).apply {
|
).apply {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
|
||||||
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
|
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,12 +32,12 @@ object Metadata {
|
||||||
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
||||||
|
|
||||||
// types of metadata
|
// types of metadata
|
||||||
|
const val TYPE_COMMENT = "comment"
|
||||||
const val TYPE_EXIF = "exif"
|
const val TYPE_EXIF = "exif"
|
||||||
const val TYPE_ICC_PROFILE = "icc_profile"
|
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||||
const val TYPE_IPTC = "iptc"
|
const val TYPE_IPTC = "iptc"
|
||||||
const val TYPE_JFIF = "jfif"
|
const val TYPE_JFIF = "jfif"
|
||||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||||
const val TYPE_JPEG_COMMENT = "jpeg_comment"
|
|
||||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||||
const val TYPE_XMP = "xmp"
|
const val TYPE_XMP = "xmp"
|
||||||
|
|
|
@ -54,9 +54,11 @@ object MultiPage {
|
||||||
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
|
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
|
||||||
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
|
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
|
||||||
|
|
||||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
|
|
||||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
|
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
|
||||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
|
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||||
|
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
|
||||||
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
|
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
|
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import pixy.meta.meta.Metadata
|
import pixy.meta.meta.Metadata
|
||||||
import pixy.meta.meta.MetadataEntry
|
import pixy.meta.meta.MetadataEntry
|
||||||
import pixy.meta.meta.MetadataType
|
import pixy.meta.meta.MetadataType
|
||||||
|
import pixy.meta.meta.iptc.IPTC
|
||||||
|
import pixy.meta.meta.iptc.IPTCDataSet
|
||||||
|
import pixy.meta.meta.iptc.IPTCRecord
|
||||||
import pixy.meta.meta.jpeg.JPGMeta
|
import pixy.meta.meta.jpeg.JPGMeta
|
||||||
import pixy.meta.meta.xmp.XMP
|
import pixy.meta.meta.xmp.XMP
|
||||||
import pixy.meta.string.XMLUtils
|
import pixy.meta.string.XMLUtils
|
||||||
|
@ -50,9 +54,46 @@ object PixyMetaHelper {
|
||||||
return metadataMap
|
return metadataMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getIptc(input: InputStream): List<FieldMap>? {
|
||||||
|
val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
|
||||||
|
|
||||||
|
val iptcDataList = ArrayList<FieldMap>()
|
||||||
|
iptc.dataSets.forEach { dataSetEntry ->
|
||||||
|
val tag = dataSetEntry.key
|
||||||
|
val dataSets = dataSetEntry.value
|
||||||
|
iptcDataList.add(
|
||||||
|
hashMapOf(
|
||||||
|
"record" to tag.recordNumber,
|
||||||
|
"tag" to tag.tag,
|
||||||
|
"values" to dataSets.map { it.data }.toMutableList(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return iptcDataList
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIptc(
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream,
|
||||||
|
iptcDataList: List<FieldMap>?,
|
||||||
|
) {
|
||||||
|
val iptc = iptcDataList?.flatMap {
|
||||||
|
val record = it["record"] as Int
|
||||||
|
val tag = it["tag"] as Int
|
||||||
|
val values = it["values"] as List<*>
|
||||||
|
values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
|
||||||
|
} ?: ArrayList<IPTCDataSet>()
|
||||||
|
Metadata.insertIPTC(input, output, iptc)
|
||||||
|
}
|
||||||
|
|
||||||
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
|
||||||
|
|
||||||
fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
|
fun setXmp(
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream,
|
||||||
|
xmpString: String?,
|
||||||
|
extendedXmpString: String?
|
||||||
|
) {
|
||||||
if (extendedXmpString != null) {
|
if (extendedXmpString != null) {
|
||||||
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
|
||||||
} else {
|
} else {
|
||||||
|
@ -70,12 +111,12 @@ object PixyMetaHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||||
|
TYPE_COMMENT -> MetadataType.COMMENT
|
||||||
TYPE_EXIF -> MetadataType.EXIF
|
TYPE_EXIF -> MetadataType.EXIF
|
||||||
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||||
TYPE_IPTC -> MetadataType.IPTC
|
TYPE_IPTC -> MetadataType.IPTC
|
||||||
TYPE_JFIF -> MetadataType.JPG_JFIF
|
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||||
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||||
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
|
|
||||||
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||||
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||||
TYPE_XMP -> MetadataType.XMP
|
TYPE_XMP -> MetadataType.XMP
|
||||||
|
|
|
@ -23,7 +23,7 @@ object XMP {
|
||||||
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
|
||||||
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
|
||||||
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
|
||||||
const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
|
||||||
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
|
||||||
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.metadata.avi.AviDirectory
|
import com.drew.metadata.avi.AviDirectory
|
||||||
|
@ -135,10 +136,12 @@ class SourceEntry {
|
||||||
try {
|
try {
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
|
|
||||||
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
|
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
|
||||||
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
|
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
|
||||||
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
|
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// ignore
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -27,6 +27,7 @@ import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictStrategy
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.utils.*
|
import deckers.thibault.aves.utils.*
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.canEditIptc
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||||
|
@ -460,6 +461,94 @@ abstract class ImageProvider {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun editIptc(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
trailerDiff: Int = 0,
|
||||||
|
iptc: List<FieldMap>?,
|
||||||
|
): Boolean {
|
||||||
|
if (!canEditIptc(mimeType)) {
|
||||||
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val originalFileSize = File(path).length()
|
||||||
|
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
|
var videoBytes: ByteArray? = null
|
||||||
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
|
deleteOnExit()
|
||||||
|
try {
|
||||||
|
outputStream().use { output ->
|
||||||
|
if (videoSize != null) {
|
||||||
|
// handle motion photo and embedded video separately
|
||||||
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
|
videoBytes = ByteArray(videoSize)
|
||||||
|
|
||||||
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
|
val imageBytes = ByteArray(imageSize)
|
||||||
|
input.read(imageBytes, 0, imageSize)
|
||||||
|
input.read(videoBytes, 0, videoSize)
|
||||||
|
|
||||||
|
// copy only the image to a temporary file for editing
|
||||||
|
// video will be appended after metadata modification
|
||||||
|
ByteArrayInputStream(imageBytes).use { imageInput ->
|
||||||
|
imageInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// copy original file to a temporary file for editing
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { imageInput ->
|
||||||
|
imageInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
editableFile.outputStream().use { output ->
|
||||||
|
// reopen input to read from start
|
||||||
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
|
when {
|
||||||
|
iptc != null ->
|
||||||
|
PixyMetaHelper.setIptc(input, output, iptc)
|
||||||
|
canRemoveMetadata(mimeType) ->
|
||||||
|
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
|
||||||
|
else -> {
|
||||||
|
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setIptc(input, output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoBytes != null) {
|
||||||
|
// append trailer video, if any
|
||||||
|
editableFile.appendBytes(videoBytes!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy the edited temporary file back to the original
|
||||||
|
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||||
|
|
||||||
|
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
callback.onFailure(e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// provide `editCoreXmp` to modify existing core XMP,
|
||||||
|
// or provide `coreXmp` and `extendedXmp` to set them
|
||||||
private fun editXmp(
|
private fun editXmp(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
|
@ -467,7 +556,9 @@ abstract class ImageProvider {
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
trailerDiff: Int = 0,
|
trailerDiff: Int = 0,
|
||||||
edit: (xmp: String) -> String,
|
coreXmp: String? = null,
|
||||||
|
extendedXmp: String? = null,
|
||||||
|
editCoreXmp: ((xmp: String) -> String)? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (!canEditXmp(mimeType)) {
|
if (!canEditXmp(mimeType)) {
|
||||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||||
|
@ -479,18 +570,34 @@ abstract class ImageProvider {
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = File.createTempFile("aves", null).apply {
|
||||||
deleteOnExit()
|
deleteOnExit()
|
||||||
try {
|
try {
|
||||||
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
var editedXmpString = coreXmp
|
||||||
if (xmp == null) {
|
var editedExtendedXmp = extendedXmp
|
||||||
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
|
if (editCoreXmp != null) {
|
||||||
return false
|
val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||||
|
if (pixyXmp != null) {
|
||||||
|
editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
|
||||||
|
if (pixyXmp.hasExtendedXmp()) {
|
||||||
|
editedExtendedXmp = pixyXmp.extendedXmpDocString()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
// reopen input to read from start
|
// reopen input to read from start
|
||||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||||
val editedXmpString = edit(xmp.xmpDocString())
|
if (editedXmpString != null) {
|
||||||
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
|
if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
|
||||||
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
|
Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setXmp(input, output, editedXmpString, null)
|
||||||
|
} else {
|
||||||
|
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
|
||||||
|
}
|
||||||
|
} else if (canRemoveMetadata(mimeType)) {
|
||||||
|
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
|
||||||
|
} else {
|
||||||
|
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
|
||||||
|
PixyMetaHelper.setXmp(input, output, null, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -538,7 +645,7 @@ abstract class ImageProvider {
|
||||||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||||
)
|
)
|
||||||
val newTrailerOffset = trailerOffset + diff
|
val newTrailerOffset = trailerOffset + diff
|
||||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
|
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||||
xmp.replace(
|
xmp.replace(
|
||||||
// GCamera motion photo
|
// GCamera motion photo
|
||||||
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
|
||||||
|
@ -548,7 +655,7 @@ abstract class ImageProvider {
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
|
||||||
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editOrientation(
|
fun editOrientation(
|
||||||
|
@ -679,6 +786,65 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setIptc(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
postEditScan: Boolean,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
iptc: List<FieldMap>? = null,
|
||||||
|
) {
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
|
||||||
|
val success = editIptc(
|
||||||
|
context = context,
|
||||||
|
path = path,
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
callback = callback,
|
||||||
|
iptc = iptc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (postEditScan) {
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
} else {
|
||||||
|
callback.onSuccess(HashMap())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callback.onFailure(Exception("failed to set IPTC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setXmp(
|
||||||
|
context: Context,
|
||||||
|
path: String,
|
||||||
|
uri: Uri,
|
||||||
|
mimeType: String,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
coreXmp: String? = null,
|
||||||
|
extendedXmp: String? = null,
|
||||||
|
) {
|
||||||
|
val newFields = HashMap<String, Any?>()
|
||||||
|
|
||||||
|
val success = editXmp(
|
||||||
|
context = context,
|
||||||
|
path = path,
|
||||||
|
uri = uri,
|
||||||
|
mimeType = mimeType,
|
||||||
|
callback = callback,
|
||||||
|
coreXmp = coreXmp,
|
||||||
|
extendedXmp = extendedXmp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||||
|
} else {
|
||||||
|
callback.onFailure(Exception("failed to set XMP"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeMetadataTypes(
|
fun removeMetadataTypes(
|
||||||
context: Context,
|
context: Context,
|
||||||
path: String,
|
path: String,
|
||||||
|
|
|
@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the provided URI can point to the wrong media collection,
|
||||||
|
// e.g. a GIF image with the URI `content://media/external/video/media/[ID]`
|
||||||
|
// so the effective entry URI may not match the provided URI
|
||||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||||
var found = false
|
var found = false
|
||||||
val fetched = arrayListOf<FieldMap>()
|
val fetched = arrayListOf<FieldMap>()
|
||||||
val id = uri.tryParseId()
|
val id = uri.tryParseId()
|
||||||
val onSuccess = fun(entry: FieldMap) {
|
val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
|
||||||
entry["uri"] = uri.toString()
|
val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
|
||||||
fetched.add(entry)
|
|
||||||
}
|
|
||||||
val alwaysValid = { _: Int, _: Int -> true }
|
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
|
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
|
||||||
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
|
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
|
||||||
|
@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
||||||
val foundContentIds = ArrayList<Int>()
|
val foundContentIds = HashSet<Int>()
|
||||||
fun check(context: Context, contentUri: Uri) {
|
fun check(context: Context, contentUri: Uri) {
|
||||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -23,7 +23,7 @@ object FlutterUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
lateinit var flutterLoader: FlutterLoader
|
lateinit var flutterLoader: FlutterLoader
|
||||||
FlutterUtils.runOnUiThread {
|
runOnUiThread {
|
||||||
// initialization must happen on the main thread
|
// initialization must happen on the main thread
|
||||||
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
|
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
|
||||||
startInitialization(context)
|
startInitialization(context)
|
||||||
|
|
|
@ -110,7 +110,16 @@ object MimeTypes {
|
||||||
}
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
|
fun canEditIptc(mimeType: String) = when (mimeType) {
|
||||||
|
JPEG, TIFF -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
fun canEditXmp(mimeType: String) = when (mimeType) {
|
||||||
|
JPEG, TIFF, PNG, GIF -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||||
|
|
|
@ -142,39 +142,6 @@ object PermissionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
|
|
||||||
val dirs = ArrayList<Map<String, String>>()
|
|
||||||
val sdkInt = Build.VERSION.SDK_INT
|
|
||||||
|
|
||||||
if (sdkInt >= Build.VERSION_CODES.R) {
|
|
||||||
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
|
|
||||||
val volumePaths = StorageUtils.getVolumePaths(context)
|
|
||||||
dirs.addAll(volumePaths.map {
|
|
||||||
hashMapOf(
|
|
||||||
"volumePath" to it,
|
|
||||||
"relativeDir" to "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
dirs.addAll(volumePaths.map {
|
|
||||||
hashMapOf(
|
|
||||||
"volumePath" to it,
|
|
||||||
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) {
|
|
||||||
// no SD card volume access on KitKat
|
|
||||||
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
|
|
||||||
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
|
|
||||||
dirs.addAll(nonPrimaryVolumes.map {
|
|
||||||
hashMapOf(
|
|
||||||
"volumePath" to it,
|
|
||||||
"relativeDir" to "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
directories.all {
|
directories.all {
|
||||||
|
@ -195,12 +162,14 @@ object PermissionManager {
|
||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns paths matching URIs granted by the user
|
// returns paths matching directory URIs granted by the user
|
||||||
fun getGrantedDirs(context: Context): Set<String> {
|
fun getGrantedDirs(context: Context): Set<String> {
|
||||||
val grantedDirs = HashSet<String>()
|
val grantedDirs = HashSet<String>()
|
||||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||||
dirPath?.let { grantedDirs.add(it) }
|
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
|
||||||
|
dirPath?.let { grantedDirs.add(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return grantedDirs
|
return grantedDirs
|
||||||
}
|
}
|
||||||
|
@ -208,13 +177,53 @@ object PermissionManager {
|
||||||
// returns paths accessible to the app (granted by the user or by default)
|
// returns paths accessible to the app (granted by the user or by default)
|
||||||
private fun getAccessibleDirs(context: Context): Set<String> {
|
private fun getAccessibleDirs(context: Context): Set<String> {
|
||||||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||||
// from Android R, we no longer have access permission by default on primary volume
|
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
// until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage
|
||||||
|
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
|
||||||
|
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
|
||||||
|
// from API 30 / Android 11 / R, any storage requires access permission
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
|
||||||
|
accessibleDirs.addAll(StorageUtils.getVolumePaths(context))
|
||||||
|
} else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||||
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
|
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
|
||||||
}
|
}
|
||||||
return accessibleDirs
|
return accessibleDirs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
|
||||||
|
val dirs = ArrayList<Map<String, String>>()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
|
||||||
|
val volumePaths = StorageUtils.getVolumePaths(context)
|
||||||
|
dirs.addAll(volumePaths.map {
|
||||||
|
hashMapOf(
|
||||||
|
"volumePath" to it,
|
||||||
|
"relativeDir" to "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
dirs.addAll(volumePaths.map {
|
||||||
|
hashMapOf(
|
||||||
|
"volumePath" to it,
|
||||||
|
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT
|
||||||
|
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) {
|
||||||
|
// removable storage requires access permission, at the file level
|
||||||
|
// without directory access, we consider the whole volume restricted
|
||||||
|
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
|
||||||
|
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
|
||||||
|
dirs.addAll(nonPrimaryVolumes.map {
|
||||||
|
hashMapOf(
|
||||||
|
"volumePath" to it,
|
||||||
|
"relativeDir" to "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
|
// As of Android R, `MediaStore.getDocumentUri` fails if any of the persisted
|
||||||
// URI permissions we hold points to a folder that no longer exists,
|
// URI permissions we hold points to a folder that no longer exists,
|
||||||
// so we should remove these obsolete URIs before proceeding.
|
// so we should remove these obsolete URIs before proceeding.
|
||||||
|
@ -234,6 +243,7 @@ object PermissionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||||
private fun releaseUriPermission(context: Context, it: Uri) {
|
private fun releaseUriPermission(context: Context, it: Uri) {
|
||||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||||
|
|
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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.5.31'
|
ext.kotlin_version = '1.6.0'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
|
<b>Navigation und Suche</b> ist ein wichtiger Bestandteil von <i>Aves</i>. Das Ziel besteht darin, dass Benutzer problemlos von Alben zu Fotos zu Tags zu Karten usw. wechseln können.
|
||||||
|
|
||||||
<i>Aves</i> lässt sich mit Android (von <b>API 20 bis 31</b>, d. h. von Lollipop bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.
|
<i>Aves</i> lässt sich mit Android (von <b>API 19 bis 31</b>, d. h. von KitKat bis S) mit Funktionen wie <b>App-Verknüpfungen</b> und <b>globaler Suche</b> integrieren. Es funktioniert auch als <b>Medienbetrachter und -auswahl</b>.
|
|
@ -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.
|
<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": {},
|
"@hideTooltip": {},
|
||||||
"removeTooltip": "Remove",
|
"removeTooltip": "Remove",
|
||||||
"@removeTooltip": {},
|
"@removeTooltip": {},
|
||||||
|
"resetButtonTooltip": "Reset",
|
||||||
|
"@resetButtonTooltip": {},
|
||||||
|
|
||||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||||
"@doubleBackExitMessage": {},
|
"@doubleBackExitMessage": {},
|
||||||
|
@ -145,6 +147,8 @@
|
||||||
|
|
||||||
"entryInfoActionEditDate": "Edit date & time",
|
"entryInfoActionEditDate": "Edit date & time",
|
||||||
"@entryInfoActionEditDate": {},
|
"@entryInfoActionEditDate": {},
|
||||||
|
"entryInfoActionEditTags": "Edit tags",
|
||||||
|
"@entryInfoActionEditTags": {},
|
||||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||||
"@entryInfoActionRemoveMetadata": {},
|
"@entryInfoActionRemoveMetadata": {},
|
||||||
|
|
||||||
|
@ -417,7 +421,7 @@
|
||||||
"removeEntryMetadataDialogMore": "More",
|
"removeEntryMetadataDialogMore": "More",
|
||||||
"@removeEntryMetadataDialogMore": {},
|
"@removeEntryMetadataDialogMore": {},
|
||||||
|
|
||||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?",
|
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
|
||||||
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
|
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "Playback speed",
|
"videoSpeedDialogLabel": "Playback speed",
|
||||||
|
@ -501,6 +505,17 @@
|
||||||
"@aboutCreditsWorldAtlas2": {},
|
"@aboutCreditsWorldAtlas2": {},
|
||||||
"aboutCreditsTranslators": "Translators:",
|
"aboutCreditsTranslators": "Translators:",
|
||||||
"@aboutCreditsTranslators": {},
|
"@aboutCreditsTranslators": {},
|
||||||
|
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||||
|
"@aboutCreditsTranslatorLine": {
|
||||||
|
"placeholders": {
|
||||||
|
"language": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"aboutLicenses": "Open-Source Licenses",
|
"aboutLicenses": "Open-Source Licenses",
|
||||||
"@aboutLicenses": {},
|
"@aboutLicenses": {},
|
||||||
|
@ -646,6 +661,8 @@
|
||||||
"@drawerCollectionImages": {},
|
"@drawerCollectionImages": {},
|
||||||
"drawerCollectionVideos": "Videos",
|
"drawerCollectionVideos": "Videos",
|
||||||
"@drawerCollectionVideos": {},
|
"@drawerCollectionVideos": {},
|
||||||
|
"drawerCollectionAnimated": "Animated",
|
||||||
|
"@drawerCollectionAnimated": {},
|
||||||
"drawerCollectionMotionPhotos": "Motion photos",
|
"drawerCollectionMotionPhotos": "Motion photos",
|
||||||
"@drawerCollectionMotionPhotos": {},
|
"@drawerCollectionMotionPhotos": {},
|
||||||
"drawerCollectionPanoramas": "Panoramas",
|
"drawerCollectionPanoramas": "Panoramas",
|
||||||
|
@ -791,20 +808,10 @@
|
||||||
|
|
||||||
"settingsSectionViewer": "Viewer",
|
"settingsSectionViewer": "Viewer",
|
||||||
"@settingsSectionViewer": {},
|
"@settingsSectionViewer": {},
|
||||||
"settingsViewerShowOverlayOnOpening": "Show overlay on opening",
|
|
||||||
"@settingsViewerShowOverlayOnOpening": {},
|
|
||||||
"settingsViewerShowMinimap": "Show minimap",
|
|
||||||
"@settingsViewerShowMinimap": {},
|
|
||||||
"settingsViewerShowInformation": "Show information",
|
|
||||||
"@settingsViewerShowInformation": {},
|
|
||||||
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
|
|
||||||
"@settingsViewerShowInformationSubtitle": {},
|
|
||||||
"settingsViewerShowShootingDetails": "Show shooting details",
|
|
||||||
"@settingsViewerShowShootingDetails": {},
|
|
||||||
"settingsViewerEnableOverlayBlurEffect": "Overlay blur effect",
|
|
||||||
"@settingsViewerEnableOverlayBlurEffect": {},
|
|
||||||
"settingsViewerUseCutout": "Use cutout area",
|
"settingsViewerUseCutout": "Use cutout area",
|
||||||
"@settingsViewerUseCutout": {},
|
"@settingsViewerUseCutout": {},
|
||||||
|
"settingsViewerMaximumBrightness": "Maximum brightness",
|
||||||
|
"@settingsViewerMaximumBrightness": {},
|
||||||
"settingsImageBackground": "Image background",
|
"settingsImageBackground": "Image background",
|
||||||
"@settingsImageBackground": {},
|
"@settingsImageBackground": {},
|
||||||
|
|
||||||
|
@ -821,6 +828,23 @@
|
||||||
"settingsViewerQuickActionEmpty": "No buttons",
|
"settingsViewerQuickActionEmpty": "No buttons",
|
||||||
"@settingsViewerQuickActionEmpty": {},
|
"@settingsViewerQuickActionEmpty": {},
|
||||||
|
|
||||||
|
"settingsViewerOverlayTile": "Overlay",
|
||||||
|
"@settingsViewerOverlayTile": {},
|
||||||
|
"settingsViewerOverlayTitle": "Overlay",
|
||||||
|
"@settingsViewerOverlayTitle": {},
|
||||||
|
"settingsViewerShowOverlayOnOpening": "Show on opening",
|
||||||
|
"@settingsViewerShowOverlayOnOpening": {},
|
||||||
|
"settingsViewerShowMinimap": "Show minimap",
|
||||||
|
"@settingsViewerShowMinimap": {},
|
||||||
|
"settingsViewerShowInformation": "Show information",
|
||||||
|
"@settingsViewerShowInformation": {},
|
||||||
|
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
|
||||||
|
"@settingsViewerShowInformationSubtitle": {},
|
||||||
|
"settingsViewerShowShootingDetails": "Show shooting details",
|
||||||
|
"@settingsViewerShowShootingDetails": {},
|
||||||
|
"settingsViewerEnableOverlayBlurEffect": "Blur effect",
|
||||||
|
"@settingsViewerEnableOverlayBlurEffect": {},
|
||||||
|
|
||||||
"settingsVideoPageTitle": "Video Settings",
|
"settingsVideoPageTitle": "Video Settings",
|
||||||
"@settingsVideoPageTitle": {},
|
"@settingsVideoPageTitle": {},
|
||||||
"settingsSectionVideo": "Video",
|
"settingsSectionVideo": "Video",
|
||||||
|
@ -880,8 +904,11 @@
|
||||||
"settingsSaveSearchHistory": "Save search history",
|
"settingsSaveSearchHistory": "Save search history",
|
||||||
"@settingsSaveSearchHistory": {},
|
"@settingsSaveSearchHistory": {},
|
||||||
|
|
||||||
"settingsHiddenFiltersTile": "Hidden filters",
|
"settingsHiddenItemsTile": "Hidden items",
|
||||||
"@settingsHiddenFiltersTile": {},
|
"@settingsHiddenItemsTile": {},
|
||||||
|
"settingsHiddenItemsTitle": "Hidden Items",
|
||||||
|
"@settingsHiddenItemsTitle": {},
|
||||||
|
|
||||||
"settingsHiddenFiltersTitle": "Hidden Filters",
|
"settingsHiddenFiltersTitle": "Hidden Filters",
|
||||||
"@settingsHiddenFiltersTitle": {},
|
"@settingsHiddenFiltersTitle": {},
|
||||||
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
|
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
|
||||||
|
@ -889,14 +916,10 @@
|
||||||
"settingsHiddenFiltersEmpty": "No hidden filters",
|
"settingsHiddenFiltersEmpty": "No hidden filters",
|
||||||
"@settingsHiddenFiltersEmpty": {},
|
"@settingsHiddenFiltersEmpty": {},
|
||||||
|
|
||||||
"settingsHiddenPathsTile": "Hidden paths",
|
|
||||||
"@settingsHiddenPathsTile": {},
|
|
||||||
"settingsHiddenPathsTitle": "Hidden Paths",
|
"settingsHiddenPathsTitle": "Hidden Paths",
|
||||||
"@settingsHiddenPathsTitle": {},
|
"@settingsHiddenPathsTitle": {},
|
||||||
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
|
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
|
||||||
"@settingsHiddenPathsBanner": {},
|
"@settingsHiddenPathsBanner": {},
|
||||||
"settingsHiddenPathsEmpty": "No hidden paths",
|
|
||||||
"@settingsHiddenPathsEmpty": {},
|
|
||||||
"addPathTooltip": "Add path",
|
"addPathTooltip": "Add path",
|
||||||
"@addPathTooltip": {},
|
"@addPathTooltip": {},
|
||||||
|
|
||||||
|
@ -1026,11 +1049,31 @@
|
||||||
"viewerInfoSearchSuggestionRights": "Rights",
|
"viewerInfoSearchSuggestionRights": "Rights",
|
||||||
"@viewerInfoSearchSuggestionRights": {},
|
"@viewerInfoSearchSuggestionRights": {},
|
||||||
|
|
||||||
|
"tagEditorPageTitle": "Edit Tags",
|
||||||
|
"@tagEditorPageTitle": {},
|
||||||
|
"tagEditorPageNewTagFieldLabel": "New tag",
|
||||||
|
"@tagEditorPageNewTagFieldLabel": {},
|
||||||
|
"tagEditorPageAddTagTooltip": "Add tag",
|
||||||
|
"@tagEditorPageAddTagTooltip": {},
|
||||||
|
"tagEditorSectionRecent": "Recent",
|
||||||
|
"@tagEditorSectionRecent": {},
|
||||||
|
|
||||||
"panoramaEnableSensorControl": "Enable sensor control",
|
"panoramaEnableSensorControl": "Enable sensor control",
|
||||||
"@panoramaEnableSensorControl": {},
|
"@panoramaEnableSensorControl": {},
|
||||||
"panoramaDisableSensorControl": "Disable sensor control",
|
"panoramaDisableSensorControl": "Disable sensor control",
|
||||||
"@panoramaDisableSensorControl": {},
|
"@panoramaDisableSensorControl": {},
|
||||||
|
|
||||||
"sourceViewerPageTitle": "Source",
|
"sourceViewerPageTitle": "Source",
|
||||||
"@sourceViewerPageTitle": {}
|
"@sourceViewerPageTitle": {},
|
||||||
|
|
||||||
|
"filePickerShowHiddenFiles": "Show hidden files",
|
||||||
|
"@filePickerShowHiddenFiles": {},
|
||||||
|
"filePickerDoNotShowHiddenFiles": "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": "보기",
|
"showTooltip": "보기",
|
||||||
"hideTooltip": "숨기기",
|
"hideTooltip": "숨기기",
|
||||||
"removeTooltip": "제거",
|
"removeTooltip": "제거",
|
||||||
|
"resetButtonTooltip": "복원",
|
||||||
|
|
||||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||||
|
|
||||||
|
@ -71,6 +72,7 @@
|
||||||
"videoActionSettings": "설정",
|
"videoActionSettings": "설정",
|
||||||
|
|
||||||
"entryInfoActionEditDate": "날짜와 시간 수정",
|
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||||
|
"entryInfoActionEditTags": "태그 수정",
|
||||||
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||||
|
|
||||||
"filterFavouriteLabel": "즐겨찾기",
|
"filterFavouriteLabel": "즐겨찾기",
|
||||||
|
@ -186,7 +188,7 @@
|
||||||
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||||
"removeEntryMetadataDialogMore": "더 보기",
|
"removeEntryMetadataDialogMore": "더 보기",
|
||||||
|
|
||||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
|
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다.\n\n삭제하시겠습니까?",
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "재생 배속",
|
"videoSpeedDialogLabel": "재생 배속",
|
||||||
|
|
||||||
|
@ -232,6 +234,7 @@
|
||||||
"aboutCreditsWorldAtlas1": "이 앱은",
|
"aboutCreditsWorldAtlas1": "이 앱은",
|
||||||
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
|
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
|
||||||
"aboutCreditsTranslators": "번역가:",
|
"aboutCreditsTranslators": "번역가:",
|
||||||
|
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||||
|
|
||||||
"aboutLicenses": "오픈 소스 라이선스",
|
"aboutLicenses": "오픈 소스 라이선스",
|
||||||
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
|
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
|
||||||
|
@ -292,6 +295,7 @@
|
||||||
"drawerCollectionFavourites": "즐겨찾기",
|
"drawerCollectionFavourites": "즐겨찾기",
|
||||||
"drawerCollectionImages": "사진",
|
"drawerCollectionImages": "사진",
|
||||||
"drawerCollectionVideos": "동영상",
|
"drawerCollectionVideos": "동영상",
|
||||||
|
"drawerCollectionAnimated": "애니메이션",
|
||||||
"drawerCollectionMotionPhotos": "모션 포토",
|
"drawerCollectionMotionPhotos": "모션 포토",
|
||||||
"drawerCollectionPanoramas": "파노라마",
|
"drawerCollectionPanoramas": "파노라마",
|
||||||
"drawerCollectionRaws": "Raw 이미지",
|
"drawerCollectionRaws": "Raw 이미지",
|
||||||
|
@ -372,13 +376,8 @@
|
||||||
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
||||||
|
|
||||||
"settingsSectionViewer": "뷰어",
|
"settingsSectionViewer": "뷰어",
|
||||||
"settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
|
|
||||||
"settingsViewerShowMinimap": "미니맵 표시",
|
|
||||||
"settingsViewerShowInformation": "상세 정보 표시",
|
|
||||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
|
||||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
|
||||||
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
|
|
||||||
"settingsViewerUseCutout": "컷아웃 영역 사용",
|
"settingsViewerUseCutout": "컷아웃 영역 사용",
|
||||||
|
"settingsViewerMaximumBrightness": "최대 밝기",
|
||||||
"settingsImageBackground": "이미지 배경",
|
"settingsImageBackground": "이미지 배경",
|
||||||
|
|
||||||
"settingsViewerQuickActionsTile": "빠른 작업",
|
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||||
|
@ -388,6 +387,15 @@
|
||||||
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
|
"settingsViewerQuickActionEditorAvailableButtons": "추가 가능한 버튼",
|
||||||
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
|
"settingsViewerQuickActionEmpty": "버튼이 없습니다",
|
||||||
|
|
||||||
|
"settingsViewerOverlayTile": "오버레이",
|
||||||
|
"settingsViewerOverlayTitle": "오버레이",
|
||||||
|
"settingsViewerShowOverlayOnOpening": "열릴 때 표시",
|
||||||
|
"settingsViewerShowMinimap": "미니맵 표시",
|
||||||
|
"settingsViewerShowInformation": "상세 정보 표시",
|
||||||
|
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||||
|
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||||
|
"settingsViewerEnableOverlayBlurEffect": "흐림 효과",
|
||||||
|
|
||||||
"settingsVideoPageTitle": "동영상 설정",
|
"settingsVideoPageTitle": "동영상 설정",
|
||||||
"settingsSectionVideo": "동영상",
|
"settingsSectionVideo": "동영상",
|
||||||
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
"settingsVideoShowVideos": "미디어에 동영상 표시",
|
||||||
|
@ -419,15 +427,15 @@
|
||||||
"settingsAllowErrorReporting": "오류 보고서 보내기",
|
"settingsAllowErrorReporting": "오류 보고서 보내기",
|
||||||
"settingsSaveSearchHistory": "검색기록",
|
"settingsSaveSearchHistory": "검색기록",
|
||||||
|
|
||||||
"settingsHiddenFiltersTile": "숨겨진 필터",
|
"settingsHiddenItemsTile": "숨겨진 항목",
|
||||||
|
"settingsHiddenItemsTitle": "숨겨진 항목",
|
||||||
|
|
||||||
"settingsHiddenFiltersTitle": "숨겨진 필터",
|
"settingsHiddenFiltersTitle": "숨겨진 필터",
|
||||||
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
"settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||||
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
|
"settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다",
|
||||||
|
|
||||||
"settingsHiddenPathsTile": "숨겨진 경로",
|
|
||||||
"settingsHiddenPathsTitle": "숨겨진 경로",
|
"settingsHiddenPathsTitle": "숨겨진 경로",
|
||||||
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||||
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
|
|
||||||
"addPathTooltip": "경로 추가",
|
"addPathTooltip": "경로 추가",
|
||||||
|
|
||||||
"settingsStorageAccessTile": "저장공간 접근",
|
"settingsStorageAccessTile": "저장공간 접근",
|
||||||
|
@ -496,8 +504,19 @@
|
||||||
"viewerInfoSearchSuggestionResolution": "해상도",
|
"viewerInfoSearchSuggestionResolution": "해상도",
|
||||||
"viewerInfoSearchSuggestionRights": "권리",
|
"viewerInfoSearchSuggestionRights": "권리",
|
||||||
|
|
||||||
|
"tagEditorPageTitle": "태그 수정",
|
||||||
|
"tagEditorPageNewTagFieldLabel": "새 태그",
|
||||||
|
"tagEditorPageAddTagTooltip": "태그 추가",
|
||||||
|
"tagEditorSectionRecent": "최근 이용기록",
|
||||||
|
|
||||||
"panoramaEnableSensorControl": "센서 제어 활성화",
|
"panoramaEnableSensorControl": "센서 제어 활성화",
|
||||||
"panoramaDisableSensorControl": "센서 제어 비활성화",
|
"panoramaDisableSensorControl": "센서 제어 비활성화",
|
||||||
|
|
||||||
"sourceViewerPageTitle": "소스 코드"
|
"sourceViewerPageTitle": "소스 코드",
|
||||||
|
|
||||||
|
"filePickerShowHiddenFiles": "숨겨진 파일 표시",
|
||||||
|
"filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함",
|
||||||
|
"filePickerOpenFrom": "다음에서 열기:",
|
||||||
|
"filePickerNoItems": "항목 없음",
|
||||||
|
"filePickerUseThisFolder": "이 폴더 사용"
|
||||||
}
|
}
|
||||||
|
|
|
@ -186,7 +186,7 @@
|
||||||
"removeEntryMetadataDialogTitle": "Удаление метаданных",
|
"removeEntryMetadataDialogTitle": "Удаление метаданных",
|
||||||
"removeEntryMetadataDialogMore": "Дополнительно",
|
"removeEntryMetadataDialogMore": "Дополнительно",
|
||||||
|
|
||||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?",
|
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль.\n\nВы уверены, что хотите удалить его?",
|
||||||
|
|
||||||
"videoSpeedDialogLabel": "Скорость воспроизведения",
|
"videoSpeedDialogLabel": "Скорость воспроизведения",
|
||||||
|
|
||||||
|
@ -232,6 +232,7 @@
|
||||||
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
|
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
|
||||||
"aboutCreditsWorldAtlas2": "под лицензией ISC.",
|
"aboutCreditsWorldAtlas2": "под лицензией ISC.",
|
||||||
"aboutCreditsTranslators": "Переводчики:",
|
"aboutCreditsTranslators": "Переводчики:",
|
||||||
|
"aboutCreditsTranslatorLine": "{language}: {names}",
|
||||||
|
|
||||||
"aboutLicenses": "Лицензии с открытым исходным кодом",
|
"aboutLicenses": "Лицензии с открытым исходным кодом",
|
||||||
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
|
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
|
||||||
|
@ -292,6 +293,7 @@
|
||||||
"drawerCollectionFavourites": "Избранное",
|
"drawerCollectionFavourites": "Избранное",
|
||||||
"drawerCollectionImages": "Изображения",
|
"drawerCollectionImages": "Изображения",
|
||||||
"drawerCollectionVideos": "Видео",
|
"drawerCollectionVideos": "Видео",
|
||||||
|
"drawerCollectionAnimated": "GIF",
|
||||||
"drawerCollectionMotionPhotos": "Живые фото",
|
"drawerCollectionMotionPhotos": "Живые фото",
|
||||||
"drawerCollectionPanoramas": "Панорамы",
|
"drawerCollectionPanoramas": "Панорамы",
|
||||||
"drawerCollectionRaws": "RAW",
|
"drawerCollectionRaws": "RAW",
|
||||||
|
@ -372,12 +374,6 @@
|
||||||
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
|
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
|
||||||
|
|
||||||
"settingsSectionViewer": "Просмотрщик",
|
"settingsSectionViewer": "Просмотрщик",
|
||||||
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
|
|
||||||
"settingsViewerShowMinimap": "Показать миникарту",
|
|
||||||
"settingsViewerShowInformation": "Показывать информацию",
|
|
||||||
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
|
|
||||||
"settingsViewerShowShootingDetails": "Показать детали съёмки",
|
|
||||||
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
|
|
||||||
"settingsViewerUseCutout": "Использовать область выреза",
|
"settingsViewerUseCutout": "Использовать область выреза",
|
||||||
"settingsImageBackground": "Фон изображения",
|
"settingsImageBackground": "Фон изображения",
|
||||||
|
|
||||||
|
@ -388,6 +384,15 @@
|
||||||
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
|
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
|
||||||
"settingsViewerQuickActionEmpty": "Нет кнопок",
|
"settingsViewerQuickActionEmpty": "Нет кнопок",
|
||||||
|
|
||||||
|
"settingsViewerOverlayTile": "Наложение",
|
||||||
|
"settingsViewerOverlayTitle": "Наложение",
|
||||||
|
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
|
||||||
|
"settingsViewerShowMinimap": "Показать миникарту",
|
||||||
|
"settingsViewerShowInformation": "Показывать информацию",
|
||||||
|
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
|
||||||
|
"settingsViewerShowShootingDetails": "Показать детали съёмки",
|
||||||
|
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
|
||||||
|
|
||||||
"settingsVideoPageTitle": "Настройки видео",
|
"settingsVideoPageTitle": "Настройки видео",
|
||||||
"settingsSectionVideo": "Видео",
|
"settingsSectionVideo": "Видео",
|
||||||
"settingsVideoShowVideos": "Показывать видео",
|
"settingsVideoShowVideos": "Показывать видео",
|
||||||
|
@ -419,15 +424,15 @@
|
||||||
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
|
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
|
||||||
"settingsSaveSearchHistory": "Сохранять историю поиска",
|
"settingsSaveSearchHistory": "Сохранять историю поиска",
|
||||||
|
|
||||||
"settingsHiddenFiltersTile": "Скрытые фильтры",
|
"settingsHiddenItemsTile": "Скрытые объекты",
|
||||||
|
"settingsHiddenItemsTitle": "Скрытые объекты",
|
||||||
|
|
||||||
"settingsHiddenFiltersTitle": "Скрытые фильтры",
|
"settingsHiddenFiltersTitle": "Скрытые фильтры",
|
||||||
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
|
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
|
||||||
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
|
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
|
||||||
|
|
||||||
"settingsHiddenPathsTile": "Скрытые каталоги",
|
|
||||||
"settingsHiddenPathsTitle": "Скрытые каталоги",
|
"settingsHiddenPathsTitle": "Скрытые каталоги",
|
||||||
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
|
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
|
||||||
"settingsHiddenPathsEmpty": "Нет скрытых каталогов",
|
|
||||||
"addPathTooltip": "Добавить каталог",
|
"addPathTooltip": "Добавить каталог",
|
||||||
|
|
||||||
"settingsStorageAccessTile": "Доступ к хранилищу",
|
"settingsStorageAccessTile": "Доступ к хранилищу",
|
||||||
|
@ -496,8 +501,16 @@
|
||||||
"viewerInfoSearchSuggestionResolution": "Разрешение",
|
"viewerInfoSearchSuggestionResolution": "Разрешение",
|
||||||
"viewerInfoSearchSuggestionRights": "Права",
|
"viewerInfoSearchSuggestionRights": "Права",
|
||||||
|
|
||||||
|
"tagEditorSectionRecent": "Недавние",
|
||||||
|
|
||||||
"panoramaEnableSensorControl": "Включить сенсорное управление",
|
"panoramaEnableSensorControl": "Включить сенсорное управление",
|
||||||
"panoramaDisableSensorControl": "Отключить сенсорное управление",
|
"panoramaDisableSensorControl": "Отключить сенсорное управление",
|
||||||
|
|
||||||
"sourceViewerPageTitle": "Источник"
|
"sourceViewerPageTitle": "Источник",
|
||||||
|
|
||||||
|
"filePickerShowHiddenFiles": "Показывать скрытые файлы",
|
||||||
|
"filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы",
|
||||||
|
"filePickerOpenFrom": "Открыть",
|
||||||
|
"filePickerNoItems": "Ничего нет.",
|
||||||
|
"filePickerUseThisFolder": "Использовать эту папку"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryInfoAction {
|
enum EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
editDate,
|
editDate,
|
||||||
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
// motion photo
|
// motion photo
|
||||||
viewMotionPhotoVideo,
|
viewMotionPhotoVideo,
|
||||||
|
@ -13,6 +14,7 @@ enum EntryInfoAction {
|
||||||
class EntryInfoActions {
|
class EntryInfoActions {
|
||||||
static const all = [
|
static const all = [
|
||||||
EntryInfoAction.editDate,
|
EntryInfoAction.editDate,
|
||||||
|
EntryInfoAction.editTags,
|
||||||
EntryInfoAction.removeMetadata,
|
EntryInfoAction.removeMetadata,
|
||||||
EntryInfoAction.viewMotionPhotoVideo,
|
EntryInfoAction.viewMotionPhotoVideo,
|
||||||
];
|
];
|
||||||
|
@ -24,6 +26,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return context.l10n.entryInfoActionRemoveMetadata;
|
return context.l10n.entryInfoActionRemoveMetadata;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
@ -41,6 +45,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
||||||
// general
|
// general
|
||||||
case EntryInfoAction.editDate:
|
case EntryInfoAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntryInfoAction.editTags:
|
||||||
|
return AIcons.addTag;
|
||||||
case EntryInfoAction.removeMetadata:
|
case EntryInfoAction.removeMetadata:
|
||||||
return AIcons.clear;
|
return AIcons.clear;
|
||||||
// motion photo
|
// motion photo
|
||||||
|
|
|
@ -26,6 +26,7 @@ enum EntrySetAction {
|
||||||
rotateCW,
|
rotateCW,
|
||||||
flip,
|
flip,
|
||||||
editDate,
|
editDate,
|
||||||
|
editTags,
|
||||||
removeMetadata,
|
removeMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +105,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return context.l10n.entryActionFlip;
|
return context.l10n.entryActionFlip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return context.l10n.entryInfoActionEditDate;
|
return context.l10n.entryInfoActionEditDate;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
return context.l10n.entryInfoActionEditTags;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return context.l10n.entryInfoActionRemoveMetadata;
|
return context.l10n.entryInfoActionRemoveMetadata;
|
||||||
}
|
}
|
||||||
|
@ -158,6 +161,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
||||||
return AIcons.flip;
|
return AIcons.flip;
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
return AIcons.date;
|
return AIcons.date;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
return AIcons.addTag;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return AIcons.clear;
|
return AIcons.clear;
|
||||||
}
|
}
|
||||||
|
|
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/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
|
@ -17,6 +18,8 @@ abstract class AvesAvailability {
|
||||||
|
|
||||||
Future<bool> get canLocatePlaces;
|
Future<bool> get canLocatePlaces;
|
||||||
|
|
||||||
|
Future<bool> get canUseGoogleMaps;
|
||||||
|
|
||||||
Future<bool> get isNewVersionAvailable;
|
Future<bool> get isNewVersionAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,6 +62,9 @@ class LiveAvesAvailability implements AvesAvailability {
|
||||||
@override
|
@override
|
||||||
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> get isNewVersionAvailable async {
|
Future<bool> get isNewVersionAvailable async {
|
||||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
|
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable!);
|
||||||
|
|
43
lib/model/device.dart
Normal file
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:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
enum EntryDataType { basic, catalog, address, references }
|
||||||
|
|
||||||
class AvesEntry {
|
class AvesEntry {
|
||||||
String uri;
|
String uri;
|
||||||
String? _path, _directory, _filename, _extension;
|
String? _path, _directory, _filename, _extension;
|
||||||
|
@ -235,6 +237,10 @@ class AvesEntry {
|
||||||
|
|
||||||
bool get canEdit => path != null;
|
bool get canEdit => path != null;
|
||||||
|
|
||||||
|
bool get canEditDate => canEdit && canEditExif;
|
||||||
|
|
||||||
|
bool get canEditTags => canEdit && canEditXmp;
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||||
|
@ -250,6 +256,30 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
bool get canEditIptc {
|
||||||
|
switch (mimeType.toLowerCase()) {
|
||||||
|
case MimeTypes.jpeg:
|
||||||
|
case MimeTypes.tiff:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// as of latest PixyMeta
|
||||||
|
bool get canEditXmp {
|
||||||
|
switch (mimeType.toLowerCase()) {
|
||||||
|
case MimeTypes.gif:
|
||||||
|
case MimeTypes.jpeg:
|
||||||
|
case MimeTypes.png:
|
||||||
|
case MimeTypes.tiff:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// as of latest PixyMeta
|
// as of latest PixyMeta
|
||||||
bool get canRemoveMetadata {
|
bool get canRemoveMetadata {
|
||||||
switch (mimeType.toLowerCase()) {
|
switch (mimeType.toLowerCase()) {
|
||||||
|
@ -394,11 +424,11 @@ class AvesEntry {
|
||||||
|
|
||||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||||
|
|
||||||
List<String>? _xmpSubjects;
|
Set<String>? _tags;
|
||||||
|
|
||||||
List<String> get xmpSubjects {
|
Set<String> get tags {
|
||||||
_xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
|
_tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {};
|
||||||
return _xmpSubjects!;
|
return _tags!;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _bestTitle;
|
String? _bestTitle;
|
||||||
|
@ -408,13 +438,15 @@ class AvesEntry {
|
||||||
return _bestTitle;
|
return _bestTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
int? get catalogDateMillis => _catalogDateMillis;
|
||||||
|
|
||||||
set catalogDateMillis(int? dateMillis) {
|
set catalogDateMillis(int? dateMillis) {
|
||||||
_catalogDateMillis = dateMillis;
|
_catalogDateMillis = dateMillis;
|
||||||
_bestDate = null;
|
_bestDate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CatalogMetadata? get catalogMetadata => _catalogMetadata;
|
||||||
|
|
||||||
set catalogMetadata(CatalogMetadata? newMetadata) {
|
set catalogMetadata(CatalogMetadata? newMetadata) {
|
||||||
final oldDateModifiedSecs = dateModifiedSecs;
|
final oldDateModifiedSecs = dateModifiedSecs;
|
||||||
final oldRotationDegrees = rotationDegrees;
|
final oldRotationDegrees = rotationDegrees;
|
||||||
|
@ -423,8 +455,8 @@ class AvesEntry {
|
||||||
catalogDateMillis = newMetadata?.dateMillis;
|
catalogDateMillis = newMetadata?.dateMillis;
|
||||||
_catalogMetadata = newMetadata;
|
_catalogMetadata = newMetadata;
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
_xmpSubjects = null;
|
_tags = null;
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notify();
|
||||||
|
|
||||||
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
}
|
}
|
||||||
|
@ -434,7 +466,7 @@ class AvesEntry {
|
||||||
addressDetails = null;
|
addressDetails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> catalog({required bool background, required bool persist, required bool force}) async {
|
Future<void> catalog({required bool background, required bool force, required bool persist}) async {
|
||||||
if (isCatalogued && !force) return;
|
if (isCatalogued && !force) return;
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
// vector image sizing is not essential, so we should not spend time for it during loading
|
// vector image sizing is not essential, so we should not spend time for it during loading
|
||||||
|
@ -466,7 +498,7 @@ class AvesEntry {
|
||||||
|
|
||||||
set addressDetails(AddressDetails? newAddress) {
|
set addressDetails(AddressDetails? newAddress) {
|
||||||
_addressDetails = newAddress;
|
_addressDetails = newAddress;
|
||||||
addressChangeNotifier.notifyListeners();
|
addressChangeNotifier.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
|
||||||
|
@ -590,61 +622,83 @@ class AvesEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
metadataChangeNotifier.notifyListeners();
|
metadataChangeNotifier.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
|
Future<void> refresh({
|
||||||
_catalogMetadata = null;
|
required bool background,
|
||||||
_addressDetails = null;
|
required bool persist,
|
||||||
|
required Set<EntryDataType> dataTypes,
|
||||||
|
required Locale geocoderLocale,
|
||||||
|
}) async {
|
||||||
|
// clear derived fields
|
||||||
_bestDate = null;
|
_bestDate = null;
|
||||||
_bestTitle = null;
|
_bestTitle = null;
|
||||||
_xmpSubjects = null;
|
_tags = null;
|
||||||
|
|
||||||
if (persist) {
|
if (persist) {
|
||||||
await metadataDb.removeIds({contentId!}, metadataOnly: true);
|
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
|
||||||
if (updated != null) {
|
if (updatedEntry != null) {
|
||||||
await _applyNewFields(updated.toMap(), persist: persist);
|
await _applyNewFields(updatedEntry.toMap(), persist: persist);
|
||||||
await catalog(background: background, persist: persist, force: force);
|
|
||||||
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
|
|
||||||
}
|
}
|
||||||
|
await catalog(background: background, force: dataTypes.contains(EntryDataType.catalog), persist: persist);
|
||||||
|
await locate(background: background, force: dataTypes.contains(EntryDataType.address), geocoderLocale: geocoderLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> rotate({required bool clockwise, required bool persist}) async {
|
Future<Set<EntryDataType>> rotate({required bool clockwise, required bool persist}) async {
|
||||||
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
||||||
if (newFields.isEmpty) return false;
|
if (newFields.isEmpty) return {};
|
||||||
|
|
||||||
await _applyNewFields(newFields, persist: persist);
|
await _applyNewFields(newFields, persist: persist);
|
||||||
return true;
|
return {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> flip({required bool persist}) async {
|
Future<Set<EntryDataType>> flip({required bool persist}) async {
|
||||||
final newFields = await metadataEditService.flip(this);
|
final newFields = await metadataEditService.flip(this);
|
||||||
if (newFields.isEmpty) return false;
|
if (newFields.isEmpty) return {};
|
||||||
|
|
||||||
await _applyNewFields(newFields, persist: persist);
|
await _applyNewFields(newFields, persist: persist);
|
||||||
return true;
|
return {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> editDate(DateModifier modifier) async {
|
Future<Set<EntryDataType>> editDate(DateModifier modifier) async {
|
||||||
if (modifier.action == DateEditAction.extractFromTitle) {
|
if (modifier.action == DateEditAction.extractFromTitle) {
|
||||||
final _title = bestTitle;
|
final _title = bestTitle;
|
||||||
if (_title == null) return false;
|
if (_title == null) return {};
|
||||||
final date = parseUnknownDateFormat(_title);
|
final date = parseUnknownDateFormat(_title);
|
||||||
if (date == null) {
|
if (date == null) {
|
||||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
await reportService.recordError('failed to parse date from title=$_title', null);
|
||||||
return false;
|
return {};
|
||||||
}
|
}
|
||||||
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
||||||
}
|
}
|
||||||
final newFields = await metadataEditService.editDate(this, modifier);
|
final newFields = await metadataEditService.editDate(this, modifier);
|
||||||
return newFields.isNotEmpty;
|
return newFields.isEmpty
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> removeMetadata(Set<MetadataType> types) async {
|
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
||||||
final newFields = await metadataEditService.removeTypes(this, types);
|
final newFields = await metadataEditService.removeTypes(this, types);
|
||||||
return newFields.isNotEmpty;
|
return newFields.isEmpty
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
EntryDataType.basic,
|
||||||
|
EntryDataType.catalog,
|
||||||
|
EntryDataType.address,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> delete() {
|
Future<bool> delete() {
|
||||||
|
@ -665,7 +719,7 @@ class AvesEntry {
|
||||||
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
|
||||||
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
|
||||||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||||
imageChangeNotifier.notifyListeners();
|
imageChangeNotifier.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
extension ExtraAvesEntry on AvesEntry {
|
extension ExtraAvesEntryImages on AvesEntry {
|
||||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||||
|
|
||||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||||
|
|
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(
|
return PaletteGenerator.fromImageProvider(
|
||||||
AppIconImage(packageName: packageName, size: 24),
|
AppIconImage(packageName: packageName, size: 24),
|
||||||
).then((palette) async {
|
).then((palette) async {
|
||||||
final color = palette.dominantColor?.color ?? (await super.color(context));
|
// `dominantColor` is most representative but can have low contrast with a dark background
|
||||||
|
// `vibrantColor` is usually representative and has good contrast with a dark background
|
||||||
|
final color = palette.vibrantColor?.color ?? (await super.color(context));
|
||||||
_appColors[album] = color;
|
_appColors[album] = color;
|
||||||
return color;
|
return color;
|
||||||
});
|
});
|
||||||
|
|
|
@ -31,6 +31,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
||||||
];
|
];
|
||||||
|
|
||||||
static CollectionFilter? fromJson(String jsonString) {
|
static CollectionFilter? fromJson(String jsonString) {
|
||||||
|
if (jsonString.isEmpty) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final jsonMap = jsonDecode(jsonString);
|
final jsonMap = jsonDecode(jsonString);
|
||||||
if (jsonMap is Map<String, dynamic>) {
|
if (jsonMap is Map<String, dynamic>) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -58,15 +59,17 @@ class LocationFilter extends CollectionFilter {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
|
||||||
final flag = countryCodeToFlag(_countryCode);
|
if (_countryCode != null && device.canRenderFlagEmojis) {
|
||||||
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
|
final flag = countryCodeToFlag(_countryCode);
|
||||||
// not filled with the shadow color as expected, so we remove them
|
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
|
||||||
if (flag != null) {
|
// not filled with the shadow color as expected, so we remove them
|
||||||
return Text(
|
if (flag != null) {
|
||||||
flag,
|
return Text(
|
||||||
style: TextStyle(fontSize: size, shadows: const []),
|
flag,
|
||||||
textScaleFactor: 1.0,
|
style: TextStyle(fontSize: size, shadows: const []),
|
||||||
);
|
textScaleFactor: 1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
|
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,9 +14,9 @@ class TagFilter extends CollectionFilter {
|
||||||
|
|
||||||
TagFilter(this.tag) {
|
TagFilter(this.tag) {
|
||||||
if (tag.isEmpty) {
|
if (tag.isEmpty) {
|
||||||
_test = (entry) => entry.xmpSubjects.isEmpty;
|
_test = (entry) => entry.tags.isEmpty;
|
||||||
} else {
|
} else {
|
||||||
_test = (entry) => entry.xmpSubjects.contains(tag);
|
_test = (entry) => entry.tags.contains(tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,8 @@ enum DateEditAction {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MetadataType {
|
enum MetadataType {
|
||||||
|
// JPEG COM marker or GIF comment
|
||||||
|
comment,
|
||||||
// Exif: https://en.wikipedia.org/wiki/Exif
|
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||||
exif,
|
exif,
|
||||||
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||||
|
@ -23,8 +25,6 @@ enum MetadataType {
|
||||||
jfif,
|
jfif,
|
||||||
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||||
jpegAdobe,
|
jpegAdobe,
|
||||||
// JPEG COM marker
|
|
||||||
jpegComment,
|
|
||||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||||
jpegDucky,
|
jpegDucky,
|
||||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||||
|
@ -42,6 +42,7 @@ class MetadataTypes {
|
||||||
static const common = {
|
static const common = {
|
||||||
MetadataType.exif,
|
MetadataType.exif,
|
||||||
MetadataType.xmp,
|
MetadataType.xmp,
|
||||||
|
MetadataType.comment,
|
||||||
MetadataType.iccProfile,
|
MetadataType.iccProfile,
|
||||||
MetadataType.iptc,
|
MetadataType.iptc,
|
||||||
MetadataType.photoshopIrb,
|
MetadataType.photoshopIrb,
|
||||||
|
@ -50,7 +51,6 @@ class MetadataTypes {
|
||||||
static const jpeg = {
|
static const jpeg = {
|
||||||
MetadataType.jfif,
|
MetadataType.jfif,
|
||||||
MetadataType.jpegAdobe,
|
MetadataType.jpegAdobe,
|
||||||
MetadataType.jpegComment,
|
|
||||||
MetadataType.jpegDucky,
|
MetadataType.jpegDucky,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -59,6 +59,8 @@ extension ExtraMetadataType on MetadataType {
|
||||||
// match `ExifInterface` directory names
|
// match `ExifInterface` directory names
|
||||||
String getText() {
|
String getText() {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
case MetadataType.comment:
|
||||||
|
return 'Comment';
|
||||||
case MetadataType.exif:
|
case MetadataType.exif:
|
||||||
return 'Exif';
|
return 'Exif';
|
||||||
case MetadataType.iccProfile:
|
case MetadataType.iccProfile:
|
||||||
|
@ -69,8 +71,6 @@ extension ExtraMetadataType on MetadataType {
|
||||||
return 'JFIF';
|
return 'JFIF';
|
||||||
case MetadataType.jpegAdobe:
|
case MetadataType.jpegAdobe:
|
||||||
return 'Adobe JPEG';
|
return 'Adobe JPEG';
|
||||||
case MetadataType.jpegComment:
|
|
||||||
return 'JpegComment';
|
|
||||||
case MetadataType.jpegDucky:
|
case MetadataType.jpegDucky:
|
||||||
return 'Ducky';
|
return 'Ducky';
|
||||||
case MetadataType.photoshopIrb:
|
case MetadataType.photoshopIrb:
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class OverlayMetadata {
|
@immutable
|
||||||
final String? aperture, exposureTime, focalLength, iso;
|
class OverlayMetadata extends Equatable {
|
||||||
|
final double? aperture, focalLength;
|
||||||
|
final String? exposureTime;
|
||||||
|
final int? iso;
|
||||||
|
|
||||||
static final apertureFormat = NumberFormat('0.0', 'en_US');
|
@override
|
||||||
static final focalLengthFormat = NumberFormat('0.#', 'en_US');
|
List<Object?> get props => [aperture, exposureTime, focalLength, iso];
|
||||||
|
|
||||||
OverlayMetadata({
|
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
||||||
double? aperture,
|
|
||||||
|
const OverlayMetadata({
|
||||||
|
this.aperture,
|
||||||
this.exposureTime,
|
this.exposureTime,
|
||||||
double? focalLength,
|
this.focalLength,
|
||||||
int? iso,
|
this.iso,
|
||||||
}) : aperture = aperture != null ? 'ƒ/${apertureFormat.format(aperture)}' : null,
|
});
|
||||||
focalLength = focalLength != null ? '${focalLengthFormat.format(focalLength)} mm' : null,
|
|
||||||
iso = iso != null ? 'ISO$iso' : null;
|
|
||||||
|
|
||||||
factory OverlayMetadata.fromMap(Map map) {
|
factory OverlayMetadata.fromMap(Map map) {
|
||||||
return OverlayMetadata(
|
return OverlayMetadata(
|
||||||
|
@ -24,9 +27,4 @@ class OverlayMetadata {
|
||||||
iso: map['iso'] as int?,
|
iso: map['iso'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ abstract class MetadataDb {
|
||||||
|
|
||||||
Future<void> reset();
|
Future<void> reset();
|
||||||
|
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly});
|
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
|
||||||
|
|
||||||
// entries
|
// entries
|
||||||
|
|
||||||
|
@ -187,20 +187,28 @@ class SqfliteMetadataDb implements MetadataDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
|
||||||
if (contentIds.isEmpty) return;
|
if (contentIds.isEmpty) return;
|
||||||
|
|
||||||
|
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||||
|
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
const where = 'contentId = ?';
|
const where = 'contentId = ?';
|
||||||
contentIds.forEach((id) {
|
contentIds.forEach((id) {
|
||||||
final whereArgs = [id];
|
final whereArgs = [id];
|
||||||
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
if (_dataTypes.contains(EntryDataType.basic)) {
|
||||||
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
batch.delete(entryTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
}
|
||||||
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
if (_dataTypes.contains(EntryDataType.catalog)) {
|
||||||
if (!metadataOnly) {
|
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
|
||||||
|
batch.delete(metadataTable, where: where, whereArgs: whereArgs);
|
||||||
|
}
|
||||||
|
if (_dataTypes.contains(EntryDataType.address)) {
|
||||||
|
batch.delete(addressTable, where: where, whereArgs: whereArgs);
|
||||||
|
}
|
||||||
|
if (_dataTypes.contains(EntryDataType.references)) {
|
||||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||||
|
|
|
@ -4,6 +4,13 @@ import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class Query extends ChangeNotifier {
|
class Query extends ChangeNotifier {
|
||||||
|
Query({required String? initialValue}) {
|
||||||
|
if (initialValue != null && initialValue.isNotEmpty) {
|
||||||
|
_enabled = true;
|
||||||
|
queryNotifier.value = initialValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _enabled = false;
|
bool _enabled = false;
|
||||||
|
|
||||||
bool get enabled => _enabled;
|
bool get enabled => _enabled;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/utils/geo_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
@ -16,24 +17,36 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _separator = ', ';
|
||||||
|
|
||||||
String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
|
String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case CoordinateFormat.dms:
|
case CoordinateFormat.dms:
|
||||||
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
|
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(_separator);
|
||||||
case CoordinateFormat.decimal:
|
case CoordinateFormat.decimal:
|
||||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
return _toDecimal(l10n, latLng).join(_separator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||||
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
|
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
|
||||||
|
final locale = l10n.localeName;
|
||||||
final lat = latLng.latitude;
|
final lat = latLng.latitude;
|
||||||
final lng = latLng.longitude;
|
final lng = latLng.longitude;
|
||||||
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals);
|
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals, locale);
|
||||||
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals);
|
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals, locale);
|
||||||
return [
|
return [
|
||||||
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
|
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
|
||||||
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
|
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<String> _toDecimal(AppLocalizations l10n, LatLng latLng) {
|
||||||
|
final locale = l10n.localeName;
|
||||||
|
final formatter = NumberFormat('0.000000°', locale);
|
||||||
|
return [
|
||||||
|
formatter.format(latLng.latitude),
|
||||||
|
formatter.format(latLng.longitude),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,7 @@ class SettingsDefaults {
|
||||||
static const showOverlayShootingDetails = false;
|
static const showOverlayShootingDetails = false;
|
||||||
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
|
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
|
||||||
static const viewerUseCutout = true;
|
static const viewerUseCutout = true;
|
||||||
|
static const viewerMaxBrightness = false;
|
||||||
|
|
||||||
// video
|
// video
|
||||||
static const videoQuickActions = [
|
static const videoQuickActions = [
|
||||||
|
@ -98,4 +99,7 @@ class SettingsDefaults {
|
||||||
// accessibility
|
// accessibility
|
||||||
static const accessibilityAnimations = AccessibilityAnimations.system;
|
static const accessibilityAnimations = AccessibilityAnimations.system;
|
||||||
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
|
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
|
||||||
|
|
||||||
|
// file picker
|
||||||
|
static const filePickerShowHiddenFiles = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,7 @@ class Settings extends ChangeNotifier {
|
||||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||||
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
|
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
|
||||||
static const viewerUseCutoutKey = 'viewer_use_cutout';
|
static const viewerUseCutoutKey = 'viewer_use_cutout';
|
||||||
|
static const viewerMaxBrightnessKey = 'viewer_max_brightness';
|
||||||
|
|
||||||
// video
|
// video
|
||||||
static const videoQuickActionsKey = 'video_quick_actions';
|
static const videoQuickActionsKey = 'video_quick_actions';
|
||||||
|
@ -116,6 +117,9 @@ class Settings extends ChangeNotifier {
|
||||||
// version
|
// version
|
||||||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
static const lastVersionCheckDateKey = 'last_version_check_date';
|
||||||
|
|
||||||
|
// file picker
|
||||||
|
static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files';
|
||||||
|
|
||||||
// platform settings
|
// platform settings
|
||||||
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
|
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
|
||||||
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
|
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
|
||||||
|
@ -152,8 +156,8 @@ class Settings extends ChangeNotifier {
|
||||||
enableOverlayBlurEffect = performanceClass >= 29;
|
enableOverlayBlurEffect = performanceClass >= 29;
|
||||||
|
|
||||||
// availability
|
// availability
|
||||||
final hasPlayServices = await availability.hasPlayServices;
|
final canUseGoogleMaps = await availability.canUseGoogleMaps;
|
||||||
if (hasPlayServices) {
|
if (canUseGoogleMaps) {
|
||||||
infoMapStyle = EntryMapStyle.googleNormal;
|
infoMapStyle = EntryMapStyle.googleNormal;
|
||||||
} else {
|
} else {
|
||||||
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
|
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
|
||||||
|
@ -352,6 +356,10 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
|
set viewerUseCutout(bool newValue) => setAndNotify(viewerUseCutoutKey, newValue);
|
||||||
|
|
||||||
|
bool get viewerMaxBrightness => getBoolOrDefault(viewerMaxBrightnessKey, SettingsDefaults.viewerMaxBrightness);
|
||||||
|
|
||||||
|
set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue);
|
||||||
|
|
||||||
// video
|
// video
|
||||||
|
|
||||||
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
|
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
|
||||||
|
@ -446,6 +454,12 @@ class Settings extends ChangeNotifier {
|
||||||
|
|
||||||
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
|
set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
// file picker
|
||||||
|
|
||||||
|
bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles);
|
||||||
|
|
||||||
|
set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue);
|
||||||
|
|
||||||
// convenience methods
|
// convenience methods
|
||||||
|
|
||||||
// ignore: avoid_positional_boolean_parameters
|
// ignore: avoid_positional_boolean_parameters
|
||||||
|
@ -587,10 +601,12 @@ class Settings extends ChangeNotifier {
|
||||||
case showOverlayShootingDetailsKey:
|
case showOverlayShootingDetailsKey:
|
||||||
case enableOverlayBlurEffectKey:
|
case enableOverlayBlurEffectKey:
|
||||||
case viewerUseCutoutKey:
|
case viewerUseCutoutKey:
|
||||||
|
case viewerMaxBrightnessKey:
|
||||||
case enableVideoHardwareAccelerationKey:
|
case enableVideoHardwareAccelerationKey:
|
||||||
case enableVideoAutoPlayKey:
|
case enableVideoAutoPlayKey:
|
||||||
case subtitleShowOutlineKey:
|
case subtitleShowOutlineKey:
|
||||||
case saveSearchHistoryKey:
|
case saveSearchHistoryKey:
|
||||||
|
case filePickerShowHiddenFilesKey:
|
||||||
if (value is bool) {
|
if (value is bool) {
|
||||||
_prefs!.setBool(key, value);
|
_prefs!.setBool(key, value);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
|
@ -10,6 +11,7 @@ import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/query.dart';
|
import 'package:aves/model/filters/query.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
import 'package:aves/model/source/events.dart';
|
||||||
import 'package:aves/model/source/location.dart';
|
import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/section_keys.dart';
|
import 'package:aves/model/source/section_keys.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
|
@ -49,7 +51,12 @@ class CollectionLens with ChangeNotifier {
|
||||||
final sourceEvents = source.eventBus;
|
final sourceEvents = source.eventBus;
|
||||||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
|
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
|
||||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
|
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
|
||||||
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
|
||||||
|
if (e.type == MoveType.move) {
|
||||||
|
// refreshing copied items is already handled via `EntryAddedEvent`s
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
}));
|
||||||
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
||||||
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||||
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
|
@ -11,6 +12,7 @@ import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/analysis_controller.dart';
|
import 'package:aves/model/source/analysis_controller.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
import 'package:aves/model/source/events.dart';
|
||||||
import 'package:aves/model/source/location.dart';
|
import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
import 'package:aves/services/analysis_service.dart';
|
import 'package:aves/services/analysis_service.dart';
|
||||||
|
@ -104,7 +106,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach((entry) => entry.catalogDateMillis = _savedDates[entry.contentId]);
|
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
|
||||||
|
entry.catalogDateMillis = _savedDates[entry.contentId];
|
||||||
|
});
|
||||||
|
|
||||||
_entryById.addAll(newIdMapEntries);
|
_entryById.addAll(newIdMapEntries);
|
||||||
_rawEntries.addAll(entries);
|
_rawEntries.addAll(entries);
|
||||||
|
@ -183,8 +187,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _moveEntry(entry, newFields, persist: persist);
|
await _moveEntry(entry, newFields, persist: persist);
|
||||||
entry.metadataChangeNotifier.notifyListeners();
|
entry.metadataChangeNotifier.notify();
|
||||||
eventBus.fire(EntryMovedEvent({entry}));
|
eventBus.fire(EntryMovedEvent(MoveType.move, {entry}));
|
||||||
completer.complete(true);
|
completer.complete(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -245,6 +249,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
title: newFields['title'] as String?,
|
title: newFields['title'] as String?,
|
||||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
debugPrint('failed to find source entry with uri=$sourceUri');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await metadataDb.saveEntries(movedEntries);
|
await metadataDb.saveEntries(movedEntries);
|
||||||
|
@ -273,7 +279,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
invalidateAlbumFilterSummary(directories: fromAlbums);
|
invalidateAlbumFilterSummary(directories: fromAlbums);
|
||||||
_invalidate(movedEntries);
|
_invalidate(movedEntries);
|
||||||
eventBus.fire(EntryMovedEvent(movedEntries));
|
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get initialized => false;
|
bool get initialized => false;
|
||||||
|
@ -284,8 +290,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
|
|
||||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
|
||||||
|
|
||||||
Future<void> refreshEntry(AvesEntry entry) async {
|
Future<void> refreshEntry(AvesEntry entry, Set<EntryDataType> dataTypes) async {
|
||||||
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
|
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
|
||||||
updateDerivedFilters({entry});
|
updateDerivedFilters({entry});
|
||||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||||
}
|
}
|
||||||
|
@ -381,46 +387,3 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
|
||||||
class EntryAddedEvent {
|
|
||||||
final Set<AvesEntry>? entries;
|
|
||||||
|
|
||||||
const EntryAddedEvent([this.entries]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class EntryRemovedEvent {
|
|
||||||
final Set<AvesEntry> entries;
|
|
||||||
|
|
||||||
const EntryRemovedEvent(this.entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class EntryMovedEvent {
|
|
||||||
final Set<AvesEntry> entries;
|
|
||||||
|
|
||||||
const EntryMovedEvent(this.entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class EntryRefreshedEvent {
|
|
||||||
final Set<AvesEntry> entries;
|
|
||||||
|
|
||||||
const EntryRefreshedEvent(this.entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class FilterVisibilityChangedEvent {
|
|
||||||
final Set<CollectionFilter> filters;
|
|
||||||
final bool visible;
|
|
||||||
|
|
||||||
const FilterVisibilityChangedEvent(this.filters, this.visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class ProgressEvent {
|
|
||||||
final int done, total;
|
|
||||||
|
|
||||||
const ProgressEvent({required this.done, required this.total});
|
|
||||||
}
|
|
||||||
|
|
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
|
// clean up obsolete entries
|
||||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||||
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
|
await metadataDb.removeIds(obsoleteContentIds);
|
||||||
|
|
||||||
// verify paths because some apps move files without updating their `last modified date`
|
// verify paths because some apps move files without updating their `last modified date`
|
||||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||||
|
|
|
@ -38,7 +38,7 @@ mixin TagMixin on SourceBase {
|
||||||
var stopCheckCount = 0;
|
var stopCheckCount = 0;
|
||||||
final newMetadata = <CatalogMetadata>{};
|
final newMetadata = <CatalogMetadata>{};
|
||||||
for (final entry in todo) {
|
for (final entry in todo) {
|
||||||
await entry.catalog(background: true, persist: true, force: force);
|
await entry.catalog(background: true, force: force, persist: true);
|
||||||
if (entry.isCatalogued) {
|
if (entry.isCatalogued) {
|
||||||
newMetadata.add(entry.catalogMetadata!);
|
newMetadata.add(entry.catalogMetadata!);
|
||||||
if (newMetadata.length >= commitCountThreshold) {
|
if (newMetadata.length >= commitCountThreshold) {
|
||||||
|
@ -63,7 +63,7 @@ mixin TagMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateTags() {
|
void updateTags() {
|
||||||
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
final updatedTags = visibleEntries.expand((entry) => entry.tags).toSet().toList()..sort(compareAsciiUpperCase);
|
||||||
if (!listEquals(updatedTags, sortedTags)) {
|
if (!listEquals(updatedTags, sortedTags)) {
|
||||||
sortedTags = List.unmodifiable(updatedTags);
|
sortedTags = List.unmodifiable(updatedTags);
|
||||||
invalidateTagFilterSummary();
|
invalidateTagFilterSummary();
|
||||||
|
@ -85,7 +85,7 @@ mixin TagMixin on SourceBase {
|
||||||
_filterEntryCountMap.clear();
|
_filterEntryCountMap.clear();
|
||||||
_filterRecentEntryMap.clear();
|
_filterRecentEntryMap.clear();
|
||||||
} else {
|
} else {
|
||||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
|
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.tags).toSet();
|
||||||
tags.forEach(_filterEntryCountMap.remove);
|
tags.forEach(_filterEntryCountMap.remove);
|
||||||
}
|
}
|
||||||
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||||
|
|
|
@ -81,7 +81,9 @@ class VideoMetadataFormatter {
|
||||||
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
|
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
|
||||||
final mediaInfo = await getVideoMetadata(entry);
|
final mediaInfo = await getVideoMetadata(entry);
|
||||||
|
|
||||||
bool isDefined(dynamic value) => value is String && value != '0';
|
// only consider values with at least 8 characters (yyyymmdd),
|
||||||
|
// ignoring unset values like `0`, as well as year values like `2021`
|
||||||
|
bool isDefined(dynamic value) => value is String && value.length >= 8;
|
||||||
|
|
||||||
var dateString = mediaInfo[Keys.date];
|
var dateString = mediaInfo[Keys.date];
|
||||||
if (!isDefined(dateString)) {
|
if (!isDefined(dateString)) {
|
||||||
|
@ -112,6 +114,7 @@ class VideoMetadataFormatter {
|
||||||
|
|
||||||
// `DateTime` does not recognize:
|
// `DateTime` does not recognize:
|
||||||
// - `UTC 2021-05-30 19:14:21`
|
// - `UTC 2021-05-30 19:14:21`
|
||||||
|
// - `2021`
|
||||||
|
|
||||||
final match = _anotherDatePattern.firstMatch(dateString);
|
final match = _anotherDatePattern.firstMatch(dateString);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
|
@ -371,7 +374,7 @@ class VideoMetadataFormatter {
|
||||||
|
|
||||||
static String _formatFilesize(String value) {
|
static String _formatFilesize(String value) {
|
||||||
final size = int.tryParse(value);
|
final size = int.tryParse(value);
|
||||||
return size != null ? formatFilesize(size) : value;
|
return size != null ? formatFileSize('en_US', size) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _formatLanguage(String value) {
|
static String _formatLanguage(String value) {
|
||||||
|
@ -396,20 +399,10 @@ class VideoMetadataFormatter {
|
||||||
if (parsed == null) return size;
|
if (parsed == null) return size;
|
||||||
size = parsed;
|
size = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const divider = 1000;
|
const divider = 1000;
|
||||||
|
|
||||||
if (size < divider) return '$size $unit';
|
if (size < divider) return '$size $unit';
|
||||||
|
if (size < divider * divider) return '${(size / divider).toStringAsFixed(round)} K$unit';
|
||||||
if (size < divider * divider && size % divider == 0) {
|
|
||||||
return '${(size / divider).toStringAsFixed(0)} K$unit';
|
|
||||||
}
|
|
||||||
if (size < divider * divider) {
|
|
||||||
return '${(size / divider).toStringAsFixed(round)} K$unit';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size < divider * divider * divider && size % divider == 0) {
|
|
||||||
return '${(size / (divider * divider)).toStringAsFixed(0)} M$unit';
|
|
||||||
}
|
|
||||||
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
|
return '${(size / divider / divider).toStringAsFixed(round)} M$unit';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
6
lib/ref/iptc.dart
Normal file
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/model/source/source_state.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:fijkplayer/fijkplayer.dart';
|
import 'package:fijkplayer/fijkplayer.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
class AnalysisService {
|
class AnalysisService {
|
||||||
|
|
|
@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/math_utils.dart';
|
import 'package:aves/utils/math_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
@ -29,8 +28,6 @@ abstract class AndroidAppService {
|
||||||
|
|
||||||
Future<bool> shareSingle(String uri, String mimeType);
|
Future<bool> shareSingle(String uri, String mimeType);
|
||||||
|
|
||||||
Future<bool> canPinToHomeScreen();
|
|
||||||
|
|
||||||
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
|
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService {
|
||||||
|
|
||||||
// app shortcuts
|
// app shortcuts
|
||||||
|
|
||||||
// this ability will not change over the lifetime of the app
|
|
||||||
bool? _canPin;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> canPinToHomeScreen() async {
|
|
||||||
if (_canPin != null) return SynchronousFuture(_canPin!);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await platform.invokeMethod('canPin');
|
|
||||||
if (result != null) {
|
|
||||||
_canPin = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
} on PlatformException catch (e, stack) {
|
|
||||||
await reportService.recordError(e, stack);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
|
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
|
||||||
Uint8List? iconBytes;
|
Uint8List? iconBytes;
|
||||||
|
@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('pin', <String, dynamic>{
|
await platform.invokeMethod('pinShortcut', <String, dynamic>{
|
||||||
'label': label,
|
'label': label,
|
||||||
'iconBytes': iconBytes,
|
'iconBytes': iconBytes,
|
||||||
'filters': filters?.map((filter) => filter.toJson()).toList(),
|
'filters': filters?.map((filter) => filter.toJson()).toList(),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
// adapted from Flutter `_OutputBuffer` in `/foundation/consolidate_response.dart`
|
||||||
class OutputBuffer extends ByteConversionSinkBase {
|
class OutputBuffer extends ByteConversionSinkBase {
|
||||||
List<List<int>>? _chunks = <List<int>>[];
|
List<List<int>>? _chunks = <List<int>>[];
|
||||||
int _contentLength = 0;
|
int _contentLength = 0;
|
||||||
|
@ -21,8 +21,8 @@ class OutputBuffer extends ByteConversionSinkBase {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_bytes = Uint8List(_contentLength);
|
_bytes = Uint8List(_contentLength);
|
||||||
var offset = 0;
|
int offset = 0;
|
||||||
for (final chunk in _chunks!) {
|
for (final List<int> chunk in _chunks!) {
|
||||||
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
_bytes!.setRange(offset, offset + chunk.length, chunk);
|
||||||
offset += chunk.length;
|
offset += chunk.length;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
abstract class DeviceService {
|
abstract class DeviceService {
|
||||||
|
Future<Map<String, dynamic>> getCapabilities();
|
||||||
|
|
||||||
Future<String?> getDefaultTimeZone();
|
Future<String?> getDefaultTimeZone();
|
||||||
|
|
||||||
Future<int> getPerformanceClass();
|
Future<int> getPerformanceClass();
|
||||||
|
@ -10,6 +12,17 @@ abstract class DeviceService {
|
||||||
class PlatformDeviceService implements DeviceService {
|
class PlatformDeviceService implements DeviceService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/device');
|
static const platform = MethodChannel('deckers.thibault/aves/device');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> getCapabilities() async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getCapabilities');
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> getDefaultTimeZone() async {
|
Future<String?> getDefaultTimeZone() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
// names should match possible values on platform
|
// names should match possible values on platform
|
||||||
enum NameConflictStrategy { rename, replace, skip }
|
enum NameConflictStrategy { rename, replace, skip }
|
||||||
|
|
||||||
extension ExtraNameConflictStrategy on NameConflictStrategy {
|
extension ExtraNameConflictStrategy on NameConflictStrategy {
|
||||||
String toPlatform() => toString().substring('NameConflictStrategy.'.length);
|
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
|
||||||
|
String toPlatform() => describeEnum(this);
|
||||||
|
|
||||||
String getName(BuildContext context) {
|
String getName(BuildContext context) {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
|
|
|
@ -159,7 +159,7 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
int? pageId,
|
int? pageId,
|
||||||
int? expectedContentLength,
|
int? expectedContentLength,
|
||||||
BytesReceivedCallback? onBytesReceived,
|
BytesReceivedCallback? onBytesReceived,
|
||||||
}) {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final completer = Completer<Uint8List>.sync();
|
final completer = Completer<Uint8List>.sync();
|
||||||
final sink = OutputBuffer();
|
final sink = OutputBuffer();
|
||||||
|
@ -191,11 +191,12 @@ class PlatformMediaFileService implements MediaFileService {
|
||||||
},
|
},
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
return completer.future;
|
// `await` here, so that `completeError` will be caught below
|
||||||
|
return await completer.future;
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
return Future.sync(() => Uint8List(0));
|
return Uint8List(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/metadata/enums.dart';
|
import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
|
@ -13,6 +14,10 @@ abstract class MetadataEditService {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan});
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp);
|
||||||
|
|
||||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,6 +90,40 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> setIptc(AvesEntry entry, List<Map<String, dynamic>>? iptc, {required bool postEditScan}) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('setIptc', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
'iptc': iptc,
|
||||||
|
'postEditScan': postEditScan,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> setXmp(AvesEntry entry, AvesXmp? xmp) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('setXmp', <String, dynamic>{
|
||||||
|
'entry': _toPlatformEntryMap(entry),
|
||||||
|
'xmp': xmp?.xmpString,
|
||||||
|
'extendedXmp': xmp?.extendedXmpString,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||||
try {
|
try {
|
||||||
|
@ -116,6 +155,8 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
|
|
||||||
String _toPlatformMetadataType(MetadataType type) {
|
String _toPlatformMetadataType(MetadataType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case MetadataType.comment:
|
||||||
|
return 'comment';
|
||||||
case MetadataType.exif:
|
case MetadataType.exif:
|
||||||
return 'exif';
|
return 'exif';
|
||||||
case MetadataType.iccProfile:
|
case MetadataType.iccProfile:
|
||||||
|
@ -126,8 +167,6 @@ class PlatformMetadataEditService implements MetadataEditService {
|
||||||
return 'jfif';
|
return 'jfif';
|
||||||
case MetadataType.jpegAdobe:
|
case MetadataType.jpegAdobe:
|
||||||
return 'jpeg_adobe';
|
return 'jpeg_adobe';
|
||||||
case MetadataType.jpegComment:
|
|
||||||
return 'jpeg_comment';
|
|
||||||
case MetadataType.jpegDucky:
|
case MetadataType.jpegDucky:
|
||||||
return 'jpeg_ducky';
|
return 'jpeg_ducky';
|
||||||
case MetadataType.photoshopIrb:
|
case MetadataType.photoshopIrb:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/metadata/catalog.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata/overlay.dart';
|
import 'package:aves/model/metadata/overlay.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
|
@ -20,6 +21,10 @@ abstract class MetadataFetchService {
|
||||||
|
|
||||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry);
|
||||||
|
|
||||||
|
Future<AvesXmp?> getXmp(AvesEntry entry);
|
||||||
|
|
||||||
Future<bool> hasContentResolverProp(String prop);
|
Future<bool> hasContentResolverProp(String prop);
|
||||||
|
|
||||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||||
|
@ -151,6 +156,39 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Map<String, dynamic>>?> getIptc(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getIptc', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
});
|
||||||
|
if (result != null) return (result as List).cast<Map>().map((fields) => fields.cast<String, dynamic>()).toList();
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AvesXmp?> getXmp(AvesEntry entry) async {
|
||||||
|
try {
|
||||||
|
final result = await platform.invokeMethod('getXmp', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'sizeBytes': entry.sizeBytes,
|
||||||
|
});
|
||||||
|
if (result != null) return AvesXmp.fromList((result as List).cast<String>());
|
||||||
|
} on PlatformException catch (e, stack) {
|
||||||
|
if (!entry.isMissingAtPath) {
|
||||||
|
await reportService.recordError(e, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, bool> _contentResolverProps = {};
|
final Map<String, bool> _contentResolverProps = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -37,8 +37,6 @@ abstract class StorageService {
|
||||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||||
|
|
||||||
Future<Uint8List> openFile(String mimeType);
|
Future<Uint8List> openFile(String mimeType);
|
||||||
|
|
||||||
Future<String?> selectDirectory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlatformStorageService implements StorageService {
|
class PlatformStorageService implements StorageService {
|
||||||
|
@ -174,7 +172,8 @@ class PlatformStorageService implements StorageService {
|
||||||
},
|
},
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
return completer.future;
|
// `await` here, so that `completeError` will be caught below
|
||||||
|
return await completer.future;
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
|
@ -198,7 +197,8 @@ class PlatformStorageService implements StorageService {
|
||||||
},
|
},
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
return completer.future;
|
// `await` here, so that `completeError` will be caught below
|
||||||
|
return await completer.future;
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
|
@ -222,7 +222,8 @@ class PlatformStorageService implements StorageService {
|
||||||
},
|
},
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
return completer.future;
|
// `await` here, so that `completeError` will be caught below
|
||||||
|
return await completer.future;
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
|
@ -249,31 +250,11 @@ class PlatformStorageService implements StorageService {
|
||||||
},
|
},
|
||||||
cancelOnError: true,
|
cancelOnError: true,
|
||||||
);
|
);
|
||||||
return completer.future;
|
// `await` here, so that `completeError` will be caught below
|
||||||
|
return await completer.future;
|
||||||
} on PlatformException catch (e, stack) {
|
} on PlatformException catch (e, stack) {
|
||||||
await reportService.recordError(e, stack);
|
await reportService.recordError(e, stack);
|
||||||
}
|
}
|
||||||
return Uint8List(0);
|
return Uint8List(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String?> selectDirectory() async {
|
|
||||||
try {
|
|
||||||
final completer = Completer<String?>();
|
|
||||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
|
||||||
'op': 'selectDirectory',
|
|
||||||
}).listen(
|
|
||||||
(data) => completer.complete(data as String?),
|
|
||||||
onError: completer.completeError,
|
|
||||||
onDone: () {
|
|
||||||
if (!completer.isCompleted) completer.complete(null);
|
|
||||||
},
|
|
||||||
cancelOnError: true,
|
|
||||||
);
|
|
||||||
return completer.future;
|
|
||||||
} on PlatformException catch (e, stack) {
|
|
||||||
await reportService.recordError(e, stack);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ class Durations {
|
||||||
// info animations
|
// info animations
|
||||||
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
static const mapStyleSwitchAnimation = Duration(milliseconds: 300);
|
||||||
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
|
||||||
|
static const tagEditorTransition = Duration(milliseconds: 200);
|
||||||
|
|
||||||
// settings animations
|
// settings animations
|
||||||
static const quickActionListAnimation = Duration(milliseconds: 200);
|
static const quickActionListAnimation = Duration(milliseconds: 200);
|
||||||
|
|
|
@ -14,11 +14,13 @@ class AIcons {
|
||||||
static const IconData date = Icons.calendar_today_outlined;
|
static const IconData date = Icons.calendar_today_outlined;
|
||||||
static const IconData disc = Icons.fiber_manual_record;
|
static const IconData disc = Icons.fiber_manual_record;
|
||||||
static const IconData error = Icons.error_outline;
|
static const IconData error = Icons.error_outline;
|
||||||
|
static const IconData folder = Icons.folder_outlined;
|
||||||
static const IconData grid = Icons.grid_on_outlined;
|
static const IconData grid = Icons.grid_on_outlined;
|
||||||
static const IconData home = Icons.home_outlined;
|
static const IconData home = Icons.home_outlined;
|
||||||
static const IconData language = Icons.translate_outlined;
|
static const IconData language = Icons.translate_outlined;
|
||||||
static const IconData location = Icons.place_outlined;
|
static const IconData location = Icons.place_outlined;
|
||||||
static const IconData locationOff = Icons.location_off_outlined;
|
static const IconData locationOff = Icons.location_off_outlined;
|
||||||
|
static const IconData mainStorage = Icons.smartphone_outlined;
|
||||||
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
static const IconData privacy = MdiIcons.shieldAccountOutline;
|
||||||
static const IconData raw = Icons.raw_on_outlined;
|
static const IconData raw = Icons.raw_on_outlined;
|
||||||
static const IconData shooting = Icons.camera_outlined;
|
static const IconData shooting = Icons.camera_outlined;
|
||||||
|
@ -33,6 +35,7 @@ class AIcons {
|
||||||
// actions
|
// actions
|
||||||
static const IconData add = Icons.add_circle_outline;
|
static const IconData add = Icons.add_circle_outline;
|
||||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||||
|
static const IconData addTag = MdiIcons.tagPlusOutline;
|
||||||
static const IconData replay10 = Icons.replay_10_outlined;
|
static const IconData replay10 = Icons.replay_10_outlined;
|
||||||
static const IconData skip10 = Icons.forward_10_outlined;
|
static const IconData skip10 = Icons.forward_10_outlined;
|
||||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||||
|
@ -66,6 +69,7 @@ class AIcons {
|
||||||
static const IconData print = Icons.print_outlined;
|
static const IconData print = Icons.print_outlined;
|
||||||
static const IconData refresh = Icons.refresh_outlined;
|
static const IconData refresh = Icons.refresh_outlined;
|
||||||
static const IconData rename = Icons.title_outlined;
|
static const IconData rename = Icons.title_outlined;
|
||||||
|
static const IconData reset = Icons.restart_alt_outlined;
|
||||||
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
||||||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||||
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
static const IconData rotateScreen = Icons.screen_rotation_outlined;
|
||||||
|
|
|
@ -32,10 +32,10 @@ class AndroidFileUtils {
|
||||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||||
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||||
avesVideoCapturesPath = pContext.join(dcimPath, 'Videocaptures');
|
avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures');
|
||||||
videoCapturesPaths = {
|
videoCapturesPaths = {
|
||||||
// from Samsung
|
// from Samsung
|
||||||
pContext.join(dcimPath, 'Video Captures'),
|
pContext.join(dcimPath, 'Videocaptures'),
|
||||||
// from Aves
|
// from Aves
|
||||||
avesVideoCapturesPath,
|
avesVideoCapturesPath,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,26 +1,9 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
// reimplemented ChangeNotifier so that it can be used anywhere, not just as a mixin
|
// `ChangeNotifier` wrapper so that it can be used anywhere, not just as a mixin
|
||||||
class AChangeNotifier implements Listenable {
|
class AChangeNotifier extends ChangeNotifier {
|
||||||
ObserverList<VoidCallback>? _listeners = ObserverList<VoidCallback>();
|
void notify() {
|
||||||
|
// why is this protected?
|
||||||
@override
|
super.notifyListeners();
|
||||||
void addListener(VoidCallback listener) => _listeners!.add(listener);
|
|
||||||
|
|
||||||
@override
|
|
||||||
void removeListener(VoidCallback listener) => _listeners!.remove(listener);
|
|
||||||
|
|
||||||
void dispose() => _listeners = null;
|
|
||||||
|
|
||||||
void notifyListeners() {
|
|
||||||
if (_listeners == null) return;
|
|
||||||
final localListeners = List<VoidCallback>.from(_listeners!);
|
|
||||||
for (final listener in localListeners) {
|
|
||||||
try {
|
|
||||||
if (_listeners!.contains(listener)) listener();
|
|
||||||
} catch (error, stack) {
|
|
||||||
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,6 +132,11 @@ class Constants {
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||||
),
|
),
|
||||||
|
Dependency(
|
||||||
|
name: 'Screen Brightness',
|
||||||
|
license: 'MIT',
|
||||||
|
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
|
||||||
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Shared Preferences',
|
name: 'Shared Preferences',
|
||||||
license: 'BSD 3-Clause',
|
license: 'BSD 3-Clause',
|
||||||
|
|
|
@ -1,38 +1,16 @@
|
||||||
String formatFilesize(int size, {int round = 2}) {
|
import 'package:intl/intl.dart';
|
||||||
var divider = 1024;
|
|
||||||
|
|
||||||
if (size < divider) return '$size B';
|
const _kiloDivider = 1024;
|
||||||
|
const _megaDivider = _kiloDivider * _kiloDivider;
|
||||||
|
const _gigaDivider = _megaDivider * _kiloDivider;
|
||||||
|
const _teraDivider = _gigaDivider * _kiloDivider;
|
||||||
|
|
||||||
if (size < divider * divider && size % divider == 0) {
|
String formatFileSize(String locale, int size, {int round = 2}) {
|
||||||
return '${(size / divider).toStringAsFixed(0)} KB';
|
if (size < _kiloDivider) return '$size B';
|
||||||
}
|
|
||||||
if (size < divider * divider) {
|
|
||||||
return '${(size / divider).toStringAsFixed(round)} KB';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size < divider * divider * divider && size % divider == 0) {
|
final formatter = NumberFormat('0${round > 0 ? '.${'0' * round}' : ''}', locale);
|
||||||
return '${(size / (divider * divider)).toStringAsFixed(0)} MB';
|
if (size < _megaDivider) return '${formatter.format(size / _kiloDivider)} KB';
|
||||||
}
|
if (size < _gigaDivider) return '${formatter.format(size / _megaDivider)} MB';
|
||||||
if (size < divider * divider * divider) {
|
if (size < _teraDivider) return '${formatter.format(size / _gigaDivider)} GB';
|
||||||
return '${(size / divider / divider).toStringAsFixed(round)} MB';
|
return '${formatter.format(size / _teraDivider)} TB';
|
||||||
}
|
|
||||||
|
|
||||||
if (size < divider * divider * divider * divider && size % divider == 0) {
|
|
||||||
return '${(size / (divider * divider * divider)).toStringAsFixed(0)} GB';
|
|
||||||
}
|
|
||||||
if (size < divider * divider * divider * divider) {
|
|
||||||
return '${(size / divider / divider / divider).toStringAsFixed(round)} GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size < divider * divider * divider * divider * divider && size % divider == 0) {
|
|
||||||
return '${(size / divider / divider / divider / divider).toStringAsFixed(0)} TB';
|
|
||||||
}
|
|
||||||
if (size < divider * divider * divider * divider * divider) {
|
|
||||||
return '${(size / divider / divider / divider / divider).toStringAsFixed(round)} TB';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size < divider * divider * divider * divider * divider * divider && size % divider == 0) {
|
|
||||||
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(0)} PB';
|
|
||||||
}
|
|
||||||
return '${(size / divider / divider / divider / divider / divider).toStringAsFixed(round)} PB';
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,23 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/utils/math_utils.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
class GeoUtils {
|
class GeoUtils {
|
||||||
static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
|
static String decimal2sexagesimal(
|
||||||
List<int> _split(final double value) {
|
double degDecimal,
|
||||||
// NumberFormat is necessary to create digit after comma if the value
|
bool minuteSecondPadding,
|
||||||
// has no decimal point (only necessary for browser)
|
int secondDecimals,
|
||||||
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
|
String locale,
|
||||||
return <int>[
|
) {
|
||||||
int.parse(tmp[0]).abs(),
|
final degAbs = degDecimal.abs();
|
||||||
int.parse(tmp[1]),
|
final deg = degAbs.toInt();
|
||||||
];
|
final minDecimal = (degAbs - deg) * 60;
|
||||||
}
|
final min = minDecimal.toInt();
|
||||||
|
|
||||||
final deg = _split(degDecimal)[0];
|
|
||||||
final minDecimal = (degDecimal.abs() - deg) * 60;
|
|
||||||
final min = _split(minDecimal)[0];
|
|
||||||
final sec = (minDecimal - min) * 60;
|
final sec = (minDecimal - min) * 60;
|
||||||
|
|
||||||
final secRounded = roundToPrecision(sec, decimals: secondDecimals);
|
var minText = NumberFormat('0' * (minuteSecondPadding ? 2 : 1), locale).format(min);
|
||||||
var minText = '$min';
|
var secText = NumberFormat('${'0' * (minuteSecondPadding ? 2 : 1)}${secondDecimals > 0 ? '.${'0' * secondDecimals}' : ''}', locale).format(sec);
|
||||||
var secText = secRounded.toStringAsFixed(secondDecimals);
|
|
||||||
if (minuteSecondPadding) {
|
|
||||||
minText = minText.padLeft(2, '0');
|
|
||||||
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '$deg° $minText′ $secText″';
|
return '$deg° $minText′ $secText″';
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,13 @@ import 'package:flutter/material.dart';
|
||||||
class AboutCredits extends StatelessWidget {
|
class AboutCredits extends StatelessWidget {
|
||||||
const AboutCredits({Key? key}) : super(key: key);
|
const AboutCredits({Key? key}) : super(key: key);
|
||||||
|
|
||||||
static const translations = [
|
static const translators = {
|
||||||
'Русский: D3ZOXY',
|
'Русский': 'D3ZOXY',
|
||||||
];
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -23,13 +24,13 @@ class AboutCredits extends StatelessWidget {
|
||||||
constraints: const BoxConstraints(minHeight: 48),
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle),
|
child: Text(l10n.aboutCredits, style: Constants.titleTextStyle),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text.rich(
|
Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
children: [
|
children: [
|
||||||
TextSpan(text: context.l10n.aboutCreditsWorldAtlas1),
|
TextSpan(text: l10n.aboutCreditsWorldAtlas1),
|
||||||
const WidgetSpan(
|
const WidgetSpan(
|
||||||
child: LinkChip(
|
child: LinkChip(
|
||||||
text: 'World Atlas',
|
text: 'World Atlas',
|
||||||
|
@ -38,17 +39,19 @@ class AboutCredits extends StatelessWidget {
|
||||||
),
|
),
|
||||||
alignment: PlaceholderAlignment.middle,
|
alignment: PlaceholderAlignment.middle,
|
||||||
),
|
),
|
||||||
TextSpan(text: context.l10n.aboutCreditsWorldAtlas2),
|
TextSpan(text: l10n.aboutCreditsWorldAtlas2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(context.l10n.aboutCreditsTranslators),
|
Text(l10n.aboutCreditsTranslators),
|
||||||
...translations.map(
|
...translators.entries.map(
|
||||||
(line) => Padding(
|
(kv) {
|
||||||
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
|
return Padding(
|
||||||
child: Text(line),
|
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
|
||||||
),
|
child: Text(l10n.aboutCreditsTranslatorLine(kv.key, kv.value)),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/app_flavor.dart';
|
import 'package:aves/app_flavor.dart';
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/settings/accessibility_animations.dart';
|
import 'package:aves/model/settings/accessibility_animations.dart';
|
||||||
import 'package:aves/model/settings/screen_on.dart';
|
import 'package:aves/model/settings/screen_on.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
@ -161,6 +163,7 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
isRotationLocked: await windowService.isRotationLocked(),
|
isRotationLocked: await windowService.isRotationLocked(),
|
||||||
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||||
);
|
);
|
||||||
|
await device.init();
|
||||||
FijkLog.setLevel(FijkLogLevel.Warn);
|
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||||
|
|
||||||
// keep screen on
|
// keep screen on
|
||||||
|
|
|
@ -10,7 +10,6 @@ import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/services/common/services.dart';
|
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||||
|
@ -46,7 +45,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||||
late AnimationController _browseToSelectAnimation;
|
late AnimationController _browseToSelectAnimation;
|
||||||
late Future<bool> _canAddShortcutsLoader;
|
|
||||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||||
final FocusNode _queryBarFocusNode = FocusNode();
|
final FocusNode _queryBarFocusNode = FocusNode();
|
||||||
late final Listenable _queryFocusRequestNotifier;
|
late final Listenable _queryFocusRequestNotifier;
|
||||||
|
@ -69,7 +67,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_isSelectingNotifier.addListener(_onActivityChange);
|
_isSelectingNotifier.addListener(_onActivityChange);
|
||||||
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
|
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
||||||
}
|
}
|
||||||
|
@ -104,53 +101,46 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||||
return FutureBuilder<bool>(
|
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
|
||||||
future: _canAddShortcutsLoader,
|
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
|
||||||
builder: (context, snapshot) {
|
builder: (context, s, child) {
|
||||||
final canAddShortcuts = snapshot.data ?? false;
|
final isSelecting = s.item1;
|
||||||
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
|
final selectedItemCount = s.item2;
|
||||||
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
|
_isSelectingNotifier.value = isSelecting;
|
||||||
builder: (context, s, child) {
|
return AnimatedBuilder(
|
||||||
final isSelecting = s.item1;
|
animation: collection.filterChangeNotifier,
|
||||||
final selectedItemCount = s.item2;
|
builder: (context, child) {
|
||||||
_isSelectingNotifier.value = isSelecting;
|
final removableFilters = appMode != AppMode.pickInternal;
|
||||||
return AnimatedBuilder(
|
return Selector<Query, bool>(
|
||||||
animation: collection.filterChangeNotifier,
|
selector: (context, query) => query.enabled,
|
||||||
builder: (context, child) {
|
builder: (context, queryEnabled, child) {
|
||||||
final removableFilters = appMode != AppMode.pickInternal;
|
return SliverAppBar(
|
||||||
return Selector<Query, bool>(
|
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||||
selector: (context, query) => query.enabled,
|
title: _buildAppBarTitle(isSelecting),
|
||||||
builder: (context, queryEnabled, child) {
|
actions: _buildActions(
|
||||||
return SliverAppBar(
|
isSelecting: isSelecting,
|
||||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
selectedItemCount: selectedItemCount,
|
||||||
title: _buildAppBarTitle(isSelecting),
|
),
|
||||||
actions: _buildActions(
|
bottom: PreferredSize(
|
||||||
isSelecting: isSelecting,
|
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||||
selectedItemCount: selectedItemCount,
|
child: Column(
|
||||||
supportShortcuts: canAddShortcuts,
|
children: [
|
||||||
),
|
if (showFilterBar)
|
||||||
bottom: PreferredSize(
|
FilterBar(
|
||||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||||
child: Column(
|
removable: removableFilters,
|
||||||
children: [
|
onTap: removableFilters ? collection.removeFilter : null,
|
||||||
if (showFilterBar)
|
),
|
||||||
FilterBar(
|
if (queryEnabled)
|
||||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
EntryQueryBar(
|
||||||
removable: removableFilters,
|
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||||
onTap: removableFilters ? collection.removeFilter : null,
|
focusNode: _queryBarFocusNode,
|
||||||
),
|
)
|
||||||
if (queryEnabled)
|
],
|
||||||
EntryQueryBar(
|
),
|
||||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
),
|
||||||
focusNode: _queryBarFocusNode,
|
titleSpacing: 0,
|
||||||
)
|
floating: true,
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
titleSpacing: 0,
|
|
||||||
floating: true,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -214,14 +204,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
List<Widget> _buildActions({
|
List<Widget> _buildActions({
|
||||||
required bool isSelecting,
|
required bool isSelecting,
|
||||||
required int selectedItemCount,
|
required int selectedItemCount,
|
||||||
required bool supportShortcuts,
|
|
||||||
}) {
|
}) {
|
||||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||||
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
|
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
|
||||||
action,
|
action,
|
||||||
appMode: appMode,
|
appMode: appMode,
|
||||||
isSelecting: isSelecting,
|
isSelecting: isSelecting,
|
||||||
supportShortcuts: supportShortcuts,
|
|
||||||
sortFactor: collection.sortFactor,
|
sortFactor: collection.sortFactor,
|
||||||
itemCount: collection.entryCount,
|
itemCount: collection.entryCount,
|
||||||
selectedItemCount: selectedItemCount,
|
selectedItemCount: selectedItemCount,
|
||||||
|
@ -269,6 +257,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||||
...[
|
...[
|
||||||
EntrySetAction.editDate,
|
EntrySetAction.editDate,
|
||||||
|
EntrySetAction.editTags,
|
||||||
EntrySetAction.removeMetadata,
|
EntrySetAction.removeMetadata,
|
||||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||||
],
|
],
|
||||||
|
@ -295,7 +284,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
}
|
}
|
||||||
|
|
||||||
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
|
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
|
||||||
Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}');
|
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
|
||||||
|
Key _getActionKey(EntrySetAction action) => Key('menu-${describeEnum(action)}');
|
||||||
|
|
||||||
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
|
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
|
||||||
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
||||||
|
@ -439,6 +429,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
_actionDelegate.onActionSelected(context, action);
|
_actionDelegate.onActionSelected(context, action);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/filters/query.dart';
|
||||||
import 'package:aves/model/selection.dart';
|
import 'package:aves/model/selection.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/collection_grid.dart';
|
import 'package:aves/widgets/collection/collection_grid.dart';
|
||||||
|
@ -8,6 +9,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/common/providers/query_provider.dart';
|
import 'package:aves/widgets/common/providers/query_provider.dart';
|
||||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -37,10 +39,12 @@ class _CollectionPageState extends State<CollectionPage> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
||||||
return MediaQueryDataProvider(
|
return MediaQueryDataProvider(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: SelectionProvider<AvesEntry>(
|
body: SelectionProvider<AvesEntry>(
|
||||||
child: QueryProvider(
|
child: QueryProvider(
|
||||||
|
initialQuery: liveFilter?.query,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) => WillPopScope(
|
builder: (context) => WillPopScope(
|
||||||
onWillPop: () {
|
onWillPop: () {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
|
import 'package:aves/widgets/common/grid/draggable_thumb_label.dart';
|
||||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -48,7 +49,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
||||||
];
|
];
|
||||||
case EntrySortFactor.size:
|
case EntrySortFactor.size:
|
||||||
return [
|
return [
|
||||||
if (entry.sizeBytes != null) formatFilesize(entry.sizeBytes!, round: 0),
|
if (entry.sizeBytes != null) formatFileSize(context.l10n.localeName, entry.sizeBytes!, round: 0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,9 @@ import 'dart:io';
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
|
import 'package:aves/model/device.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/model/entry_xmp_iptc.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
|
@ -43,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
EntrySetAction action, {
|
EntrySetAction action, {
|
||||||
required AppMode appMode,
|
required AppMode appMode,
|
||||||
required bool isSelecting,
|
required bool isSelecting,
|
||||||
required bool supportShortcuts,
|
|
||||||
required EntrySortFactor sortFactor,
|
required EntrySortFactor sortFactor,
|
||||||
required int itemCount,
|
required int itemCount,
|
||||||
required int selectedItemCount,
|
required int selectedItemCount,
|
||||||
|
@ -66,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.toggleTitleSearch:
|
case EntrySetAction.toggleTitleSearch:
|
||||||
return !isSelecting;
|
return !isSelecting;
|
||||||
case EntrySetAction.addShortcut:
|
case EntrySetAction.addShortcut:
|
||||||
return appMode == AppMode.main && !isSelecting && supportShortcuts;
|
return appMode == AppMode.main && !isSelecting && device.canPinShortcut;
|
||||||
// browsing or selecting
|
// browsing or selecting
|
||||||
case EntrySetAction.map:
|
case EntrySetAction.map:
|
||||||
case EntrySetAction.stats:
|
case EntrySetAction.stats:
|
||||||
|
@ -81,6 +82,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return appMode == AppMode.main && isSelecting;
|
return appMode == AppMode.main && isSelecting;
|
||||||
}
|
}
|
||||||
|
@ -122,6 +124,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.rotateCW:
|
case EntrySetAction.rotateCW:
|
||||||
case EntrySetAction.flip:
|
case EntrySetAction.flip:
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
|
case EntrySetAction.editTags:
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
return hasSelection;
|
return hasSelection;
|
||||||
}
|
}
|
||||||
|
@ -181,6 +184,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
case EntrySetAction.editDate:
|
case EntrySetAction.editDate:
|
||||||
_editDate(context);
|
_editDate(context);
|
||||||
break;
|
break;
|
||||||
|
case EntrySetAction.editTags:
|
||||||
|
_editTags(context);
|
||||||
|
break;
|
||||||
case EntrySetAction.removeMetadata:
|
case EntrySetAction.removeMetadata:
|
||||||
_removeMetadata(context);
|
_removeMetadata(context);
|
||||||
break;
|
break;
|
||||||
|
@ -399,7 +405,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Selection<AvesEntry> selection,
|
Selection<AvesEntry> selection,
|
||||||
Set<AvesEntry> todoItems,
|
Set<AvesEntry> todoItems,
|
||||||
Future<bool> Function(AvesEntry entry) op,
|
Future<Set<EntryDataType>> Function(AvesEntry entry) op,
|
||||||
) async {
|
) async {
|
||||||
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
||||||
final todoCount = todoItems.length;
|
final todoCount = todoItems.length;
|
||||||
|
@ -411,8 +417,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
showOpReport<ImageOpEvent>(
|
showOpReport<ImageOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||||
final success = await op(entry);
|
final dataTypes = await op(entry);
|
||||||
return ImageOpEvent(success: success, uri: entry.uri);
|
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
|
||||||
}).asBroadcastStream(),
|
}).asBroadcastStream(),
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
|
@ -470,6 +476,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
);
|
);
|
||||||
if (confirmed == null || !confirmed) return null;
|
if (confirmed == null || !confirmed) return null;
|
||||||
|
|
||||||
|
// wait for the dialog to hide as applying the change may block the UI
|
||||||
|
await Future.delayed(Durations.dialogTransitionAnimation);
|
||||||
return supported;
|
return supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -497,7 +505,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
|
||||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif);
|
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditDate);
|
||||||
if (todoItems == null || todoItems.isEmpty) return;
|
if (todoItems == null || todoItems.isEmpty) return;
|
||||||
|
|
||||||
final modifier = await selectDateModifier(context, todoItems);
|
final modifier = await selectDateModifier(context, todoItems);
|
||||||
|
@ -506,6 +514,28 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editTags(BuildContext context) async {
|
||||||
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
|
||||||
|
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditTags);
|
||||||
|
if (todoItems == null || todoItems.isEmpty) return;
|
||||||
|
|
||||||
|
final newTagsByEntry = await selectTags(context, todoItems);
|
||||||
|
if (newTagsByEntry == null) return;
|
||||||
|
|
||||||
|
// only process modified items
|
||||||
|
todoItems.removeWhere((entry) {
|
||||||
|
final newTags = newTagsByEntry[entry] ?? entry.tags;
|
||||||
|
final currentTags = entry.tags;
|
||||||
|
return newTags.length == currentTags.length && newTags.every(currentTags.contains);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todoItems.isEmpty) return;
|
||||||
|
|
||||||
|
await _edit(context, selection, todoItems, (entry) => entry.editTags(newTagsByEntry[entry]!));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _removeMetadata(BuildContext context) async {
|
Future<void> _removeMetadata(BuildContext context) async {
|
||||||
final selection = context.read<Selection<AvesEntry>>();
|
final selection = context.read<Selection<AvesEntry>>();
|
||||||
final selectedItems = _getExpandedSelectedItems(selection);
|
final selectedItems = _getExpandedSelectedItems(selection);
|
||||||
|
@ -596,6 +626,9 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
|
||||||
final name = result.item2;
|
final name = result.item2;
|
||||||
if (name.isEmpty) return;
|
if (name.isEmpty) return;
|
||||||
|
|
||||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters: filters));
|
await androidAppService.pinToHomeScreen(name, coverEntry, filters: filters);
|
||||||
|
if (!device.showPinShortcutFeedback) {
|
||||||
|
showFeedback(context, context.l10n.genericSuccessFeedback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,8 +65,8 @@ class MonthSectionHeader<T> extends StatelessWidget {
|
||||||
if (date == null) return l10n.sectionUnknown;
|
if (date == null) return l10n.sectionUnknown;
|
||||||
if (date.isThisMonth) return l10n.dateThisMonth;
|
if (date.isThisMonth) return l10n.dateThisMonth;
|
||||||
final locale = l10n.localeName;
|
final locale = l10n.localeName;
|
||||||
if (date.isThisYear) return DateFormat.MMMM(locale).format(date);
|
final localized = date.isThisYear? DateFormat.MMMM(locale).format(date) : DateFormat.yMMMM(locale).format(date);
|
||||||
return DateFormat.yMMMM(locale).format(date);
|
return '${localized.substring(0, 1).toUpperCase()}${localized.substring(1)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -42,8 +42,6 @@ class _EntryQueryBarState extends State<EntryQueryBar> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO TLAD focus on text field when enabled (`autofocus` is unusable)
|
|
||||||
// TODO TLAD lose focus on navigation to viewer?
|
|
||||||
void _registerWidget(EntryQueryBar widget) {
|
void _registerWidget(EntryQueryBar widget) {
|
||||||
widget.queryNotifier.addListener(_onQueryChanged);
|
widget.queryNotifier.addListener(_onQueryChanged);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,9 @@ import 'package:aves/model/metadata/enums.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_date_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/edit_entry_tags_dialog.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/entry_editors/remove_entry_metadata_dialog.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
mixin EntryEditorMixin {
|
mixin EntryEditorMixin {
|
||||||
|
@ -21,6 +22,23 @@ mixin EntryEditorMixin {
|
||||||
return modifier;
|
return modifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<AvesEntry, Set<String>>?> selectTags(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
final tagsByEntry = Map.fromEntries(entries.map((v) => MapEntry(v, v.tags.toSet())));
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: TagEditorPage.routeName),
|
||||||
|
builder: (context) => TagEditorPage(
|
||||||
|
tagsByEntry: tagsByEntry,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return tagsByEntry;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
|
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
if (entries.isEmpty) return null;
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
|
|
|
@ -75,13 +75,15 @@ mixin SizeAwareMixin {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final neededSize = formatFilesize(needed);
|
final l10n = context.l10n;
|
||||||
final freeSize = formatFilesize(free);
|
final locale = l10n.localeName;
|
||||||
|
final neededSize = formatFileSize(locale, needed);
|
||||||
|
final freeSize = formatFileSize(locale, free);
|
||||||
final volume = destinationVolume.getDescription(context);
|
final volume = destinationVolume.getDescription(context);
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
context: context,
|
context: context,
|
||||||
title: context.l10n.notEnoughSpaceDialogTitle,
|
title: l10n.notEnoughSpaceDialogTitle,
|
||||||
content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
|
content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
import 'package:aves/model/source/events.dart';
|
||||||
import 'package:aves/model/source/source_state.dart';
|
import 'package:aves/model/source/source_state.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
|
|
@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:highlight/highlight.dart' show highlight, Node;
|
import 'package:highlight/highlight.dart' show highlight, Node;
|
||||||
|
|
||||||
// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6
|
// adapted from package `flutter_highlight` v0.7.0 `HighlightView`
|
||||||
|
// TODO TLAD use the TextSpan getter when this is fixed: https://github.com/git-touch/highlight/issues/6
|
||||||
|
|
||||||
/// Highlight Flutter Widget
|
/// Highlight Flutter Widget
|
||||||
class AvesHighlightView extends StatelessWidget {
|
class AvesHighlightView extends StatelessWidget {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
This is derived from `draggable_scrollbar` package v0.0.4:
|
adapted from package `draggable_scrollbar` v0.0.4:
|
||||||
- removed default thumb builders
|
- removed default thumb builders
|
||||||
- allow any `ScrollView` as child
|
- allow any `ScrollView` as child
|
||||||
- allow any `Widget` as label content
|
- allow any `Widget` as label content
|
||||||
|
|
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 String? title;
|
||||||
final Iterable<CollectionFilter> filters;
|
final Iterable<CollectionFilter> filters;
|
||||||
final ValueNotifier<String?> expandedNotifier;
|
final ValueNotifier<String?> expandedNotifier;
|
||||||
|
final bool showGenericIcon;
|
||||||
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
final HeroType Function(CollectionFilter filter)? heroTypeBuilder;
|
||||||
final FilterCallback onTap;
|
final FilterCallback onTap;
|
||||||
|
final OffsetFilterCallback? onLongPress;
|
||||||
|
|
||||||
const ExpandableFilterRow({
|
const ExpandableFilterRow({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.title,
|
this.title,
|
||||||
required this.filters,
|
required this.filters,
|
||||||
required this.expandedNotifier,
|
required this.expandedNotifier,
|
||||||
|
this.showGenericIcon = true,
|
||||||
this.heroTypeBuilder,
|
this.heroTypeBuilder,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
static const double horizontalPadding = 8;
|
static const double horizontalPadding = 8;
|
||||||
|
@ -109,8 +113,10 @@ class ExpandableFilterRow extends StatelessWidget {
|
||||||
// key `album-{path}` is expected by test driver
|
// key `album-{path}` is expected by test driver
|
||||||
key: Key(filter.key),
|
key: Key(filter.key),
|
||||||
filter: filter,
|
filter: filter,
|
||||||
|
showGenericIcon: showGenericIcon,
|
||||||
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,7 +3,7 @@ import 'dart:ui' as ui;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
|
// adapted from Flutter `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
|
||||||
// to transition between 2 different fits during hero animation:
|
// to transition between 2 different fits during hero animation:
|
||||||
// - BoxFit.cover at t=0
|
// - BoxFit.cover at t=0
|
||||||
// - BoxFit.contain at t=1
|
// - BoxFit.contain at t=1
|
||||||
|
@ -190,7 +190,8 @@ class _TransitionImagePainter extends CustomPainter {
|
||||||
Offset.zero & inputSize,
|
Offset.zero & inputSize,
|
||||||
);
|
);
|
||||||
if (background != null) {
|
if (background != null) {
|
||||||
canvas.drawRect(destinationRect, Paint()..color = background!);
|
// deflate to avoid background artifact around opaque image
|
||||||
|
canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!);
|
||||||
}
|
}
|
||||||
canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
|
canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import 'package:provider/provider.dart';
|
||||||
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
// With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen
|
||||||
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
// because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0.
|
||||||
// cf https://github.com/flutter/flutter/issues/49027
|
// cf https://github.com/flutter/flutter/issues/49027
|
||||||
// adapted from `RenderSliverFixedExtentBoxAdaptor`
|
// adapted from Flutter `RenderSliverFixedExtentBoxAdaptor` in `/rendering/sliver_fixed_extent_list.dart`
|
||||||
class SectionedListSliver<T> extends StatelessWidget {
|
class SectionedListSliver<T> extends StatelessWidget {
|
||||||
const SectionedListSliver({Key? key}) : super(key: key);
|
const SectionedListSliver({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ import 'package:provider/provider.dart';
|
||||||
typedef FilterCallback = void Function(CollectionFilter filter);
|
typedef FilterCallback = void Function(CollectionFilter filter);
|
||||||
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
|
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
|
||||||
|
|
||||||
enum HeroType { always, onTap }
|
enum HeroType { always, onTap, never }
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class AvesFilterDecoration {
|
class AvesFilterDecoration {
|
||||||
|
@ -40,7 +40,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
final bool removable, showGenericIcon, useFilterColor;
|
final bool removable, showGenericIcon, useFilterColor;
|
||||||
final AvesFilterDecoration? decoration;
|
final AvesFilterDecoration? decoration;
|
||||||
final String? banner;
|
final String? banner;
|
||||||
final Widget? details;
|
final Widget? leadingOverride, details;
|
||||||
final double padding, maxWidth;
|
final double padding, maxWidth;
|
||||||
final HeroType heroType;
|
final HeroType heroType;
|
||||||
final FilterCallback? onTap;
|
final FilterCallback? onTap;
|
||||||
|
@ -64,6 +64,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
this.useFilterColor = true,
|
this.useFilterColor = true,
|
||||||
this.decoration,
|
this.decoration,
|
||||||
this.banner,
|
this.banner,
|
||||||
|
this.leadingOverride,
|
||||||
this.details,
|
this.details,
|
||||||
this.padding = 6.0,
|
this.padding = 6.0,
|
||||||
this.maxWidth = defaultMaxChipWidth,
|
this.maxWidth = defaultMaxChipWidth,
|
||||||
|
@ -162,7 +163,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
|
||||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||||
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
|
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
|
||||||
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
|
||||||
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
||||||
|
|
||||||
final decoration = widget.decoration;
|
final decoration = widget.decoration;
|
||||||
|
|
|
@ -18,13 +18,14 @@ class MagnifierController {
|
||||||
late ScaleStateChange _currentScaleState, previousScaleState;
|
late ScaleStateChange _currentScaleState, previousScaleState;
|
||||||
|
|
||||||
MagnifierController({
|
MagnifierController({
|
||||||
Offset initialPosition = Offset.zero,
|
MagnifierState? initialState,
|
||||||
}) : super() {
|
}) : super() {
|
||||||
initial = MagnifierState(
|
initial = initialState ??
|
||||||
position: initialPosition,
|
const MagnifierState(
|
||||||
scale: null,
|
position: Offset.zero,
|
||||||
source: ChangeSource.internal,
|
scale: null,
|
||||||
);
|
source: ChangeSource.internal,
|
||||||
|
);
|
||||||
previousState = initial;
|
previousState = initial;
|
||||||
_currentState = initial;
|
_currentState = initial;
|
||||||
_setState(initial);
|
_setState(initial);
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue