Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-12-22 09:20:19 +09:00
commit 369647555f
175 changed files with 3326 additions and 1668 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.5.3'
flutter-version: '2.8.1'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.5.3'
flutter-version: '2.8.1'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -52,12 +52,12 @@ jobs:
rm release.keystore.asc
mkdir outputs
(cd scripts/; ./apply_flavor_play.sh)
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.5.3.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.5.3.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh)
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.8.1.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:
@ -71,6 +71,7 @@ jobs:
uses: ncipollo/release-action@v1
with:
artifacts: "outputs/*"
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload app bundle

View file

@ -2,9 +2,27 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
## <a id="unreleased"></a>[Unreleased]
## [v1.5.7] - 2021-12-01
## <a id="v1.5.8"></a>[v1.5.8] - 2021-12-22
### Added
- Collection / Albums / Countries / Tags: list view (scalable like the grid view)
- moving, editing or deleting multiple items can be cancelled
- Viewer: option to auto play motion photos (after a small delay to show first the high-res photo)
- German translation (thanks JanWaldhorn)
### Changed
- upgraded Flutter to stable v2.8.1
### Fixed
- Collection: more consistent scroll bar thumb position to match the viewport
- Settings: fixed file selection to import settings on older devices
## <a id="v1.5.7"></a>[v1.5.7] - 2021-12-01
### Added
@ -24,7 +42,7 @@ All notable changes to this project will be documented in this file.
- double-tap gesture in the viewer was ignored in some cases
- copied items had the wrong date
## [v1.5.6] - 2021-11-12
## <a id="v1.5.6"></a>[v1.5.6] - 2021-11-12
### Added

View file

@ -35,6 +35,10 @@ Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/2-S10-viewer.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/5-S10-stats.png" alt='Stats screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/3-S10-info__basic_.png" alt='Info (basic) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/4-S10-info__metadata_.png" alt='Info (metadata) screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/6-S10-countries.png" alt='Countries screenshot' height="400" />
## Changelog
The list of changes for past and future releases is available [here](https://github.com/deckerst/aves/blob/develop/CHANGELOG.md).
## Permissions
Aves requires a few permissions to do its job:

View file

@ -56,6 +56,9 @@ android {
// minSdkVersion constraints:
// - Flutter & other plugins: 16
// - google_maps_flutter v2.1.1: 20
// - to build XML documents from XMP data, `metadata-extractor` and `PixyMeta` rely on `DocumentBuilder`,
// which implementation `DocumentBuilderImpl` is provided by the OS and is not customizable on Android,
// but the implementation on API <19 is not robust enough and fails to build XMP documents
minSdkVersion 19
targetSdkVersion 31
versionCode flutterVersionCode.toInteger()

View file

@ -155,7 +155,7 @@ class MainActivity : FlutterActivity() {
}
}
@SuppressLint("WrongConstant")
@SuppressLint("WrongConstant", "ObsoleteSdkInt")
private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.os.Build
@ -24,6 +25,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
try {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.res.Configuration
@ -63,6 +64,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
setLocale(Locale.ENGLISH)
} else {
@ -272,13 +274,13 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} else {
var mimeType = "*/*"
if (mimeTypes.size == 1) {
// items have the same mime type & subtype
// items have the same MIME type & subtype
mimeType = mimeTypes.first()
} else {
// items have different subtypes
val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct()
if (mimeTypeTypes.size == 1) {
// items have the same mime type
// items have the same MIME type
mimeType = "${mimeTypeTypes.first()}/*"
}
}

View file

@ -1,9 +1,11 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.res.Resources
import android.os.Build
import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -14,6 +16,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getLocales" -> safe(call, result, ::getLocales)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented()
}
@ -41,6 +44,32 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
result.success(TimeZone.getDefault().id)
}
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
fun toMap(locale: Locale): FieldMap {
val fields: HashMap<String, Any?> = hashMapOf(
"language" to locale.language,
"country" to locale.country,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
fields["script"] = locale.script
}
return fields
}
val locales = ArrayList<FieldMap>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// when called from a window-less service, locales from `context.resources`
// do not reflect the current system settings, so we use `Resources.getSystem()` instead
val list = Resources.getSystem().configuration.locales
for (i in 0 until list.size()) {
locales.add(toMap(list.get(i)))
}
} else {
locales.add(toMap(Locale.getDefault()))
}
result.success(locales)
}
private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.graphics.Rect
import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -14,6 +15,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
@ -34,6 +36,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
@ -138,6 +141,19 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
}
}
private fun cancelFileOp(call: MethodCall, result: MethodChannel.Result) {
val opId = call.argument<String>("opId")
if (opId == null) {
result.error("cancelFileOp-args", "failed because of missing arguments", null)
return
}
Log.i(LOG_TAG, "cancelling file op $opId")
cancelledOps.add(opId)
result.success(null)
}
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val desiredName = call.argument<String>("desiredName")
@ -169,6 +185,9 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaFileHandler>()
const val CHANNEL = "deckers.thibault/aves/media_file"
val cancelledOps = HashSet<String>()
}
}

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
@ -191,6 +192,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
@ -409,19 +411,19 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// * `metadata-extractor` sometimes detects the wrong mime type (e.g. `pef` file as `tiff`)
// * the content resolver / media store sometimes reports the wrong mime type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `metadata-extractor` sometimes detects the wrong MIME type (e.g. `pef` file as `tiff`, `mpeg` as `dvd`)
// * the content resolver / media store sometimes reports the wrong MIME type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes returns an incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000`
// * file extension is unreliable
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// In the end, `metadata-extractor` is the most reliable, except for `tiff`/`dvd` (false positives, false negatives),
// in which case we trust the file extension
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
if (path?.matches(TIFF_EXTENSION_PATTERN) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
if (it != MimeTypes.TIFF) {
if (it != MimeTypes.TIFF && it != MimeTypes.DVD) {
metadataMap[KEY_MIME_TYPE] = it
}
}
@ -584,6 +586,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
}

View file

@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.channel.calls.MediaFileHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
@ -24,11 +25,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
private lateinit var handler: Handler
private var op: String? = null
private var opId: String? = null
private val entryMapList = ArrayList<FieldMap>()
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
opId = arguments["id"] as String?
@Suppress("unchecked_cast")
val rawEntries = arguments["entries"] as List<FieldMap>?
if (rawEntries != null) {
@ -74,6 +77,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
private fun endOfStream() {
cancelledOps.remove(opId)
handler.post {
try {
eventSink.endOfStream()
@ -97,14 +101,18 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
return
}
for (entryMap in entryMapList) {
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 && mimeType != null) {
val entries = entryMapList.map(::AvesEntry)
for (entry in entries) {
val uri = entry.uri
val path = entry.path
val mimeType = entry.mimeType
val result: FieldMap = hashMapOf(
"uri" to uri.toString(),
)
if (isCancelledOp()) {
result["skipped"] = true
} else {
try {
provider.delete(activity, uri, path, mimeType)
result["success"] = true
@ -112,8 +120,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false
}
success(result)
}
success(result)
}
endOfStream()
}
@ -173,7 +181,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, object : ImageOpCallback {
provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})
@ -201,13 +209,15 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
val entries = entryMapList.map(::AvesEntry)
provider.renameMultiple(activity, newName, entries, object : ImageOpCallback {
provider.renameMultiple(activity, newName, entries, ::isCancelledOp, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
})
endOfStream()
}
private fun isCancelledOp() = cancelledOps.contains(opId)
companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_op_stream"

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
@ -36,6 +37,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
val settings: FieldMap = hashMapOf(
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
)
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
}
@ -51,6 +53,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation
changed = true
}
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
if (transitionAnimationScale != newTransitionAnimationScale) {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.streams
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.net.Uri
@ -10,6 +11,7 @@ import android.util.Log
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
@ -91,8 +93,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
private fun createFile() {
@SuppressLint("ObsoleteSdkInt")
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
}
@ -133,24 +135,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
private fun openFile() {
private suspend fun openFile() {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// TODO TLAD [<=API18] open file
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val mimeType = args["mimeType"] as String?
if (mimeType == null) {
error("openFile-args", "failed because of missing arguments", null)
return
}
val mimeType = args["mimeType"] as String? // optional
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
}
MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
fun onGranted(uri: Uri) {
GlobalScope.launch(Dispatchers.IO) {
activity.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(BUFFER_SIZE)
@ -161,11 +155,24 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
endOfStream()
}
}
}, {
}
fun onDenied() {
success(ByteArray(0))
endOfStream()
})
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
setTypeAndNormalize(mimeType ?: MimeTypes.ANY)
}
if (intent.resolveActivity(activity.packageManager) != null) {
MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, ::onGranted, ::onDenied)
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
} else {
MainActivity.notifyError("failed to resolve activity for intent=$intent")
onDenied()
}
}
override fun onCancel(arguments: Any?) {}

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.metadata
import android.annotation.SuppressLint
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.os.Build
@ -31,6 +32,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply {
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
}

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.metadata
import android.annotation.SuppressLint
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
@ -56,6 +57,7 @@ object MultiPage {
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
}

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.model
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.graphics.BitmapFactory
@ -139,6 +140,7 @@ class SourceEntry {
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
@SuppressLint("ObsoleteSdkInt")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
}
@ -161,7 +163,7 @@ class SourceEntry {
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong
// do not switch on specific MIME types, as the reported MIME type could be wrong
// (e.g. PNG registered as JPG)
if (isVideo) {
for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) {

View file

@ -47,11 +47,25 @@ abstract class ImageProvider {
throw UnsupportedOperationException("`delete` is not supported by this image provider")
}
open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List<AvesEntry>, callback: ImageOpCallback) {
open suspend fun moveMultiple(
activity: Activity,
copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
}
open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
open suspend fun renameMultiple(
activity: Activity,
newFileName: String,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
}
@ -937,3 +951,5 @@ abstract class ImageProvider {
}
}
}
typealias CancelCheck = () -> Boolean

View file

@ -175,7 +175,7 @@ class MediaStoreImageProvider : ImageProvider() {
// but for single items, `contentUri` already contains the ID
val itemUri = if (contentUriContainsId) contentUri else ContentUris.withAppendedId(contentUri, contentId.toLong())
// `mimeType` can be registered as null for file media URIs with unsupported media types (e.g. TIFF on old devices)
// in that case we try to use the mime type provided along the URI
// in that case we try to use the MIME type provided along the URI
val mimeType: String? = cursor.getString(mimeTypeColumn) ?: fileMimeType
val width = cursor.getInt(widthColumn)
val height = cursor.getInt(heightColumn)
@ -331,6 +331,7 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
@ -366,7 +367,7 @@ class MediaStoreImageProvider : ImageProvider() {
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
val newFields = moveSingle(
val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle(
activity = activity,
sourcePath = sourcePath,
sourceUri = sourceUri,
@ -505,6 +506,7 @@ class MediaStoreImageProvider : ImageProvider() {
activity: Activity,
newFileName: String,
entries: List<AvesEntry>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
for (entry in entries) {
@ -519,7 +521,7 @@ class MediaStoreImageProvider : ImageProvider() {
if (sourcePath != null) {
try {
val newFields = renameSingle(
val newFields = if (isCancelledOp()) skippedFieldMap else renameSingle(
activity = activity,
mimeType = mimeType,
oldMediaUri = sourceUri,
@ -563,6 +565,7 @@ class MediaStoreImageProvider : ImageProvider() {
throw Exception("unsupported Android version")
}
Log.d(LOG_TAG, "rename content at uri=$mediaUri")
val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType)
// `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME`

View file

@ -3,7 +3,7 @@ package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
object MimeTypes {
private const val IMAGE = "image"
const val ANY = "*/*"
// generic raster
const val BMP = "image/bmp"
@ -45,10 +45,9 @@ object MimeTypes {
// vector
const val SVG = "image/svg+xml"
private const val VIDEO = "video"
private const val AVI = "video/avi"
private const val AVI_VND = "video/vnd.avi"
const val DVD = "video/dvd"
private const val MKV = "video/x-matroska"
private const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t"
@ -57,9 +56,9 @@ object MimeTypes {
private const val OGV = "video/ogg"
private const val WEBM = "video/webm"
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith("image")
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith("video")
fun isHeic(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.utils
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
@ -182,6 +183,7 @@ object PermissionManager {
// 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
@SuppressLint("ObsoleteSdkInt")
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) {

View file

@ -93,7 +93,6 @@ object StorageUtils {
}
}
@SuppressLint("ObsoleteSdkInt")
private fun findVolumePaths(context: Context): Array<String> {
// Final set of paths
val paths = HashSet<String>()

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Suche</string>
<string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Analyse von Medien</string>
<string name="analysis_service_description">Bilder &amp; Videos scannen</string>
<string name="analysis_notification_default_title">Medien scannen</string>
<string name="analysis_notification_action_stop">Abbrechen</string>
</resources>

View file

@ -1,16 +1,16 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.6.0'
ext.kotlin_version = '1.6.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.3'
classpath 'com.android.tools.build:gradle:7.0.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics are not actually used by all flavors
classpath 'com.google.gms:google-services:4.3.10'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.0'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1'
}
}

526
lib/l10n/app_de.arb Normal file
View file

@ -0,0 +1,526 @@
{
"appName": "Aves",
"welcomeMessage": "Willkommen bei Aves",
"welcomeOptional": "Optional",
"welcomeTermsToggle": "Ich stimme den Bedingungen und Konditionen zu",
"itemCount": " {count, plural, =1{1 Element} other{{count} Elemente}}",
"timeSeconds": " {seconds, plural, =1{1 Sekunde} other{{seconds} Sekunde}}",
"timeMinutes": " {minutes, plural, =1{1 Minute} other{{minutes} Minuten}}",
"applyButtonLabel": "ANWENDEN",
"deleteButtonLabel": "LÖSCHEN",
"nextButtonLabel": "NÄCHSTE",
"showButtonLabel": "ANZEIGEN",
"hideButtonLabel": "VERBERGEN",
"continueButtonLabel": "WEITER",
"cancelTooltip": "Abbrechen",
"changeTooltip": "Ändern",
"clearTooltip": "Aufräumen",
"previousTooltip": "Vorherige",
"nextTooltip": "Nächste",
"showTooltip": "Anzeigen",
"hideTooltip": "Ausblenden",
"removeTooltip": "Entfernen",
"resetButtonTooltip": "Zurücksetzen",
"doubleBackExitMessage": "Tippen Sie zum Verlassen erneut auf „Zurück“.",
"sourceStateLoading": "Laden",
"sourceStateCataloguing": "Katalogisierung",
"sourceStateLocatingCountries": "Länder lokalisieren",
"sourceStateLocatingPlaces": "Lokalisierung von Orten",
"chipActionDelete": "Löschen",
"chipActionGoToAlbumPage": "Anzeigen in Alben",
"chipActionGoToCountryPage": "Anzeigen in Ländern",
"chipActionGoToTagPage": "Zeige in Tags",
"chipActionHide": "Ausblenden",
"chipActionPin": "Oben Anpinnen",
"chipActionUnpin": "Nicht mehr Anpinen",
"chipActionRename": "Umbenennen",
"chipActionSetCover": "Titelbild bestimmen",
"chipActionCreateAlbum": "Album erstellen",
"entryActionCopyToClipboard": "In die Zwischenablage kopieren",
"entryActionDelete": "Löschen",
"entryActionExport": "Exportieren",
"entryActionInfo": "Info",
"entryActionRename": "Umbenennen",
"entryActionRotateCCW": "Drehen gegen den Uhrzeigersinn",
"entryActionRotateCW": "Drehen im Uhrzeigersinn",
"entryActionFlip": "Horizontal spiegeln",
"entryActionPrint": "Drucken",
"entryActionShare": "Teilen",
"entryActionViewSource": "Quelle anzeigen",
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
"entryActionEdit": "Bearbeiten mit...",
"entryActionOpen": "Öffnen Sie mit...",
"entryActionSetAs": "Einstellen als...",
"entryActionOpenMap": "In der Karten-App anzeigen...",
"entryActionRotateScreen": "Bildschirm rotieren",
"entryActionAddFavourite": "Zu Favoriten hinzufügen ",
"entryActionRemoveFavourite": "Aus Favoriten entfernen",
"videoActionCaptureFrame": "Frame aufnehmen",
"videoActionPause": "Pause",
"videoActionPlay": "Spielen",
"videoActionReplay10": "10 Sekunden rückwärts springen",
"videoActionSkip10": "10 Sekunden vorwärts springen",
"videoActionSelectStreams": "Titel auswählen",
"videoActionSetSpeed": "Wiedergabegeschwindigkeit",
"videoActionSettings": "Einstellungen",
"entryInfoActionEditDate": "Datum & Uhrzeit bearbeiten",
"entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert",
"filterTypeAnimatedLabel": "Animationen",
"filterTypeMotionPhotoLabel": "Bewegtes Foto",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Rohdaten",
"filterTypeSphericalVideoLabel": "360° Video",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Bild",
"filterMimeVideoLabel": "Video",
"coordinateFormatDms": "GMS",
"coordinateFormatDecimal": "Dezimalgrad",
"coordinateDms": " {coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "s",
"coordinateDmsEast": "O",
"coordinateDmsWest": "W",
"unitSystemMetric": "Metrisch",
"unitSystemImperial": "Imperiale",
"videoLoopModeNever": "Niemals",
"videoLoopModeShortOnly": "Nur kurze Videos",
"videoLoopModeAlways": "Immer",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Gelände)",
"mapStyleOsmHot": "Humanitäres OSM",
"mapStyleStamenToner": "Stamen Toner (SchwarzWeiß)",
"mapStyleStamenWatercolor": "Stamen Aquarell",
"nameConflictStrategyRename": "Umbenennen",
"nameConflictStrategyReplace": "Ersetzen Sie",
"nameConflictStrategySkip": "Überspringen",
"keepScreenOnNever": "Niemals",
"keepScreenOnViewerOnly": "Nur bei Bildbetrachtung",
"keepScreenOnAlways": "Immer",
"accessibilityAnimationsRemove": "Verhinderung von Bildschirmeffekten",
"accessibilityAnimationsKeep": "Bildschirmeffekte beibehalten",
"albumTierNew": "Neu",
"albumTierPinned": "Angeheftet",
"albumTierSpecial": "Häufig verwendet",
"albumTierApps": "Apps",
"albumTierRegular": "Andere",
"storageVolumeDescriptionFallbackPrimary": "Interner Speicher",
"storageVolumeDescriptionFallbackNonPrimary": "SD-Karte",
"rootDirectoryDescription": "Hauptverzeichnis",
"otherDirectoryDescription": "„{name}“ Verzeichnis",
"storageAccessDialogTitle": "Speicherzugriff",
"storageAccessDialogMessage": "Bitte wählen Sie den {directory} von „{volume}“ auf dem nächsten Bildschirm, um dieser App Zugriff darauf zu geben.",
"restrictedAccessDialogTitle": "Eingeschränkter Zugang",
"restrictedAccessDialogMessage": "Diese Anwendung darf keine Dateien im {directory} von „{volume}“ verändern.\n\nBitte verwenden Sie einen vorinstallierten Dateimanager oder eine Galerie-App, um die Objekte in ein anderes Verzeichnis zu verschieben.",
"notEnoughSpaceDialogTitle": "Nicht genug Platz",
"notEnoughSpaceDialogMessage": "Diese Operation benötigt {neededSize} freien Platz auf „{volume}“, um abgeschlossen zu werden, aber es ist nur noch {freeSize} übrig.",
"unsupportedTypeDialogTitle": "Nicht unterstützte Typen",
"unsupportedTypeDialogMessage": " {count, plural, =1{Dieser Vorgang wird für Elemente des folgenden Typs nicht unterstützt: {types}.} other{Dieser Vorgang wird für Elemente der folgenden Typen nicht unterstützt: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Einige Dateien im Zielordner haben den gleichen Namen.",
"nameConflictDialogMultipleSourceMessage": "Einige Dateien haben denselben Namen.",
"addShortcutDialogLabel": "Shortcut-Etikett",
"addShortcutButtonLabel": "Hinzufügen",
"noMatchingAppDialogTitle": "Keine passende App",
"noMatchingAppDialogMessage": "Es gibt keine Anwendungen, die dies bewältigen können.",
"deleteEntriesConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie dieses Element löschen möchten?} other{Sind Sie sicher, dass Sie diese {count} Elemente löschen möchten?}}",
"videoResumeDialogMessage": "Möchten Sie bei {time} weiter abspielen?",
"videoStartOverButtonLabel": "NEU BEGINNEN",
"videoResumeButtonLabel": "FORTSETZTEN",
"setCoverDialogTitle": "Titelbild bestimmen",
"setCoverDialogLatest": "Letzter Artikel",
"setCoverDialogCustom": "Benutzerdefiniert",
"hideFilterConfirmationDialogMessage": "Passende Fotos und Videos werden aus Ihrer Sammlung ausgeblendet. Sie können sie in den „Datenschutz“-Einstellungen wieder einblenden.\n\nSind Sie sicher, dass Sie sie ausblenden möchten?",
"newAlbumDialogTitle": "Neues Album",
"newAlbumDialogNameLabel": "Album Name",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Verzeichnis existiert bereits",
"newAlbumDialogStorageLabel": "Speicher:",
"renameAlbumDialogLabel": "Neuer Name",
"renameAlbumDialogLabelAlreadyExistsHelper": "Verzeichnis existiert bereits",
"deleteSingleAlbumConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie dieses Album und ihren Inhalt löschen möchten?} other{Sind Sie sicher, dass Sie dieses Album und deren {count} Elemente löschen möchten?}}",
"deleteMultiAlbumConfirmationDialogMessage": " {count, plural, =1{Sind Sie sicher, dass Sie diese Alben und ihren Inhalt löschen möchten?} other{Sind Sie sicher, dass Sie diese Alben und deren {count} Elemente löschen möchten?}}",
"exportEntryDialogFormat": "Format:",
"renameEntryDialogLabel": "Neuer Name",
"editEntryDateDialogTitle": "Datum & Uhrzeit",
"editEntryDateDialogSet": "Festlegen",
"editEntryDateDialogShift": "Verschieben",
"editEntryDateDialogExtractFromTitle": "Auszug aus dem Titel",
"editEntryDateDialogClear": "Aufräumen",
"editEntryDateDialogFieldSelection": "Feldauswahl",
"editEntryDateDialogHours": "Stunden",
"editEntryDateDialogMinutes": "Minuten",
"removeEntryMetadataDialogTitle": "Entfernung von Metadaten",
"removeEntryMetadataDialogMore": "Mehr",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP ist erforderlich, um das Video innerhalb eines bewegten Bildes abzuspielen.\n\nSind Sie sicher, dass Sie es entfernen möchten?",
"videoSpeedDialogLabel": "Wiedergabegeschwindigkeit",
"videoStreamSelectionDialogVideo": "Video",
"videoStreamSelectionDialogAudio": "Audio",
"videoStreamSelectionDialogText": "Untertitel",
"videoStreamSelectionDialogOff": "Aus",
"videoStreamSelectionDialogTrack": "Spur",
"videoStreamSelectionDialogNoSelection": "Es gibt keine anderen Spuren.",
"genericSuccessFeedback": "Erledigt!",
"genericFailureFeedback": "Gescheitert",
"menuActionConfigureView": "Sortierung",
"menuActionSelect": "Auswahl",
"menuActionSelectAll": "Alle auswählen",
"menuActionSelectNone": "Keine auswählen",
"menuActionMap": "Karte",
"menuActionStats": "Statistiken",
"viewDialogTabSort": "Sortieren",
"viewDialogTabGroup": "Gruppe",
"viewDialogTabLayout": "Layout",
"tileLayoutGrid": "Kacheln",
"tileLayoutList": "Liste",
"aboutPageTitle": "Über",
"aboutLinkSources": "Quellen",
"aboutLinkLicense": "Lizenz",
"aboutLinkPolicy": "Datenschutzrichtlinie",
"aboutUpdate": "Neue Version verfügbar",
"aboutUpdateLinks1": "Eine neue Version von Aves ist verfügbar unter",
"aboutUpdateLinks2": "und",
"aboutUpdateLinks3": ".",
"aboutUpdateGitHub": "github",
"aboutUpdateGooglePlay": "Google Play",
"aboutBug": "Fehlerbericht",
"aboutBugSaveLogInstruction": "Anwendungsprotokolle in einer Datei speichern",
"aboutBugSaveLogButton": "Speichern",
"aboutBugCopyInfoInstruction": "Systeminformationen kopieren",
"aboutBugCopyInfoButton": "Kopieren",
"aboutBugReportInstruction": "Bericht auf GitHub mit den Protokollen und Systeminformationen",
"aboutBugReportButton": "Bericht",
"aboutCredits": "Credits",
"aboutCreditsWorldAtlas1": "Diese Anwendung verwendet eine TopoJSON-Datei von",
"aboutCreditsWorldAtlas2": "unter ISC-Lizenz.",
"aboutCreditsTranslators": "Übersetzer:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "Open-Source-Lizenzen",
"aboutLicensesBanner": "Diese Anwendung verwendet die folgenden Open-Source-Pakete und -Bibliotheken.",
"aboutLicensesAndroidLibraries": "Android-Bibliotheken",
"aboutLicensesFlutterPlugins": "Flutter-Plugins",
"aboutLicensesFlutterPackages": "Flatter-Pakete",
"aboutLicensesDartPackages": "Dart-Pakete",
"aboutLicensesShowAllButtonLabel": "Alle Lizenzen anzeigen",
"policyPageTitle": "Datenschutzrichtlinie",
"collectionPageTitle": "Sammlung",
"collectionPickPageTitle": "Wähle",
"collectionSelectionPageTitle": " {count, plural, =0{Elemente auswählen} =1{1 Element} other{{count} Elemente}}",
"collectionActionShowTitleSearch": "Titelfilter anzeigen",
"collectionActionHideTitleSearch": "Titelfilter ausblenden",
"collectionActionAddShortcut": "Verknüpfung hinzufügen",
"collectionActionCopy": "In Album kopieren",
"collectionActionMove": "Zum Album verschieben",
"collectionActionRescan": "Neu scannen",
"collectionActionEdit": "Bearbeiten",
"collectionSearchTitlesHintText": "Titel suchen",
"collectionSortDate": "Nach Datum",
"collectionSortSize": "Nach Größe",
"collectionSortName": "Nach Album & Dateiname",
"collectionGroupAlbum": "Nach Album",
"collectionGroupMonth": "Nach Monat",
"collectionGroupDay": "Nach Tag",
"collectionGroupNone": "Nicht gruppieren",
"sectionUnknown": "Unbekannt",
"dateToday": "Heute",
"dateYesterday": "Gestern",
"dateThisMonth": "Diesen Monat",
"collectionDeleteFailureFeedback": " {count, plural, =1{1 Element konnte nicht gelöscht werden} other{{count} Elemente konnten nicht gelöscht werden}}",
"collectionCopyFailureFeedback": " {count, plural, =1{1 Element konnte nicht kopiert werden} other{{count} Element konnten nicht kopiert werden}}",
"collectionMoveFailureFeedback": " {count, plural, =1{1 Element konnte nicht verschoben werden} other{{count} Elemente konnten nicht verschoben werden}}",
"collectionEditFailureFeedback": " {count, plural, =1{1 Element konnte nicht bearbeitet werden} other{{count} 1 Elemente konnten nicht bearbeitet werden}}",
"collectionExportFailureFeedback": " {count, plural, =1{1 Seite konnte nicht exportiert werden} other{{count} Seiten konnten nicht exportiert werden}}",
"collectionCopySuccessFeedback": " {count, plural, =1{1 Element kopier} other{ {count} Elemente kopiert}}",
"collectionMoveSuccessFeedback": " {count, plural, =1{1 Element verschoben} other{{count} Elemente verschoben}}",
"collectionEditSuccessFeedback": " {count, plural, =1{1 Element bearbeitet} other{ {count} Elemente bearbeitet}}",
"collectionEmptyFavourites": "Keine Favoriten",
"collectionEmptyVideos": "Keine Videos",
"collectionEmptyImages": "Keine Bilder",
"collectionSelectSectionTooltip": "Bereich auswählen",
"collectionDeselectSectionTooltip": "Bereich abwählen",
"drawerCollectionAll": "Alle Sammlung",
"drawerCollectionFavourites": "Favoriten",
"drawerCollectionImages": "Bilder",
"drawerCollectionVideos": "Videos",
"drawerCollectionAnimated": "Animationen",
"drawerCollectionMotionPhotos": "Bewegte Fotos",
"drawerCollectionPanoramas": "Panoramen",
"drawerCollectionRaws": "Rohdaten Fotos",
"drawerCollectionSphericalVideos": "360°-Videos",
"chipSortDate": "Nach Datum",
"chipSortName": "Nach Name",
"chipSortCount": "Nach Anzahl",
"albumGroupTier": "Nach Ebene",
"albumGroupVolume": "Nach Speichervolumen",
"albumGroupNone": "Nicht gruppieren",
"albumPickPageTitleCopy": "In Album kopieren",
"albumPickPageTitleExport": "In Album exportieren",
"albumPickPageTitleMove": "Zum Album verschieben",
"albumPickPageTitlePick": "Album auswählen",
"albumCamera": "Kamera",
"albumDownload": "Herunterladen",
"albumScreenshots": "Bildschirmfotos",
"albumScreenRecordings": "Bildschirmaufnahmen",
"albumVideoCaptures": "Video-Aufnahmen",
"albumPageTitle": "Alben",
"albumEmpty": "Keine Alben",
"createAlbumTooltip": "Album erstellen",
"createAlbumButtonLabel": "ERSTELLE",
"newFilterBanner": "Neu",
"countryPageTitle": "Länder",
"countryEmpty": "Keine Länder",
"tagPageTitle": "Tags",
"tagEmpty": "Keine Tags",
"searchCollectionFieldHint": "Sammlung durchsuchen",
"searchSectionRecent": "Neueste",
"searchSectionAlbums": "Alben",
"searchSectionCountries": "Länder",
"searchSectionPlaces": "Orte",
"searchSectionTags": "Tags",
"settingsPageTitle": "Einstellungen",
"settingsSystemDefault": "System",
"settingsDefault": "Standard",
"settingsActionExport": "Exportieren",
"settingsActionImport": "Importieren",
"settingsSectionNavigation": "Navigation",
"settingsHome": "Startseite",
"settingsKeepScreenOnTile": "Bildschirm eingeschaltet lassen",
"settingsKeepScreenOnTitle": "Bildschirm eingeschaltet lassen",
"settingsDoubleBackExit": "Zum Verlassen zweimal „zurück“ tippen",
"settingsNavigationDrawerTile": "Menü Navigation",
"settingsNavigationDrawerEditorTitle": "Menü Navigation",
"settingsNavigationDrawerBanner": "Berühren und halten Sie die Taste, um Menüpunkte zu verschieben und neu anzuordnen.",
"settingsNavigationDrawerTabTypes": "Typen",
"settingsNavigationDrawerTabAlbums": "Alben",
"settingsNavigationDrawerTabPages": "Seiten",
"settingsNavigationDrawerAddAlbum": "Album hinzufügen",
"settingsSectionThumbnails": "Vorschaubilder",
"settingsThumbnailShowLocationIcon": "Standort-Symbol anzeigen",
"settingsThumbnailShowMotionPhotoIcon": "Bewegungsfoto-Symbol anzeigen",
"settingsThumbnailShowRawIcon": "Rohdaten-Symbol anzeigen",
"settingsThumbnailShowVideoDuration": "Videodauer anzeigen",
"settingsCollectionQuickActionsTile": "Schnelle Aktionen",
"settingsCollectionQuickActionEditorTitle": "Schnelle Aktionen",
"settingsCollectionQuickActionTabBrowsing": "Durchsuchen",
"settingsCollectionQuickActionTabSelecting": "Auswahl",
"settingsCollectionBrowsingQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen beim Durchsuchen von Elementen angezeigt werden.",
"settingsCollectionSelectionQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen bei der Auswahl von Elementen angezeigt werden.",
"settingsSectionViewer": "Anzeige",
"settingsViewerUseCutout": "Ausgeschnittenen Bereich verwenden",
"settingsViewerMaximumBrightness": "Maximale Helligkeit",
"settingsMotionPhotoAutoPlay": "Automatische Wiedergabe bewegter Fotos",
"settingsImageBackground": "Bild-Hintergrund",
"settingsViewerQuickActionsTile": "Schnelle Aktionen",
"settingsViewerQuickActionEditorTitle": "Schnelle Aktionen",
"settingsViewerQuickActionEditorBanner": "Halten Sie die Taste gedrückt, um die Schaltflächen zu bewegen und auszuwählen, welche Aktionen im Viewer angezeigt werden sollen.",
"settingsViewerQuickActionEditorDisplayedButtons": "Angezeigte Schaltflächen",
"settingsViewerQuickActionEditorAvailableButtons": "Verfügbare Schaltflächen",
"settingsViewerQuickActionEmpty": "Keine Tasten",
"settingsViewerOverlayTile": "Überlagerung",
"settingsViewerOverlayTitle": "Überlagerung",
"settingsViewerShowOverlayOnOpening": "Bei Eröffnung anzeigen",
"settingsViewerShowMinimap": "Minimap anzeigen",
"settingsViewerShowInformation": "Informationen anzeigen",
"settingsViewerShowInformationSubtitle": "Titel, Datum, Ort, etc. anzeigen.",
"settingsViewerShowShootingDetails": "Aufnahmedetails anzeigen",
"settingsViewerEnableOverlayBlurEffect": "Unschärfe-Effekt",
"settingsVideoPageTitle": "Video-Einstellungen",
"settingsSectionVideo": "Video",
"settingsVideoShowVideos": "Videos anzeigen",
"settingsVideoEnableHardwareAcceleration": "Hardware-Beschleunigung",
"settingsVideoEnableAutoPlay": "Automatische Wiedergabe",
"settingsVideoLoopModeTile": "Schleifen-Modus",
"settingsVideoLoopModeTitle": "Schleifen-Modus",
"settingsVideoQuickActionsTile": "Schnelle Aktionen für Videos",
"settingsVideoQuickActionEditorTitle": "Schnelle Aktionen",
"settingsSubtitleThemeTile": "Untertitel",
"settingsSubtitleThemeTitle": "Untertitel",
"settingsSubtitleThemeSample": "Dies ist ein Beispiel.",
"settingsSubtitleThemeTextAlignmentTile": "Textausrichtung",
"settingsSubtitleThemeTextAlignmentTitle": "Textausrichtung",
"settingsSubtitleThemeTextSize": "Textgröße",
"settingsSubtitleThemeShowOutline": "Umriss und Schatten anzeigen",
"settingsSubtitleThemeTextColor": "Textfarbe",
"settingsSubtitleThemeTextOpacity": "Opazität des Textes",
"settingsSubtitleThemeBackgroundColor": "Hintergrundfarbe",
"settingsSubtitleThemeBackgroundOpacity": "Hintergrund-Opazität",
"settingsSubtitleThemeTextAlignmentLeft": "Links",
"settingsSubtitleThemeTextAlignmentCenter": "Zentrum",
"settingsSubtitleThemeTextAlignmentRight": "Rechts",
"settingsSectionPrivacy": "Datenschutz",
"settingsAllowInstalledAppAccess": "Zugriff auf die Liste der installierten Apps",
"settingsAllowInstalledAppAccessSubtitle": "zur Gruppierung von Bildern nach Apps",
"settingsAllowErrorReporting": "Anonyme Fehlermeldungen zulassen",
"settingsSaveSearchHistory": "Suchverlauf speichern",
"settingsHiddenItemsTile": "Versteckte Elemente",
"settingsHiddenItemsTitle": "Versteckte Gegenstände",
"settingsHiddenFiltersTitle": "Versteckte Filter",
"settingsHiddenFiltersBanner": "Fotos und Videos, die versteckten Filtern entsprechen, werden nicht in Ihrer Sammlung angezeigt.",
"settingsHiddenFiltersEmpty": "Keine versteckten Filter",
"settingsHiddenPathsTitle": "Verborgene Pfade",
"settingsHiddenPathsBanner": "Fotos und Videos, die sich in diesen Ordnern oder in einem ihrer Unterordner befinden, werden nicht in Ihrer Sammlung angezeigt.",
"addPathTooltip": "Pfad hinzufügen",
"settingsStorageAccessTile": "Speicherzugriff",
"settingsStorageAccessTitle": "Speicherzugriff",
"settingsStorageAccessBanner": "Einige Verzeichnisse erfordern eine explizite Zugriffsberechtigung, um Dateien darin zu ändern. Sie können hier Verzeichnisse überprüfen, auf die Sie zuvor Zugriff gewährt haben.",
"settingsStorageAccessEmpty": "Keine Zugangsberechtigungen",
"settingsStorageAccessRevokeTooltip": "Widerrufen",
"settingsSectionAccessibility": "Barrierefreiheit",
"settingsRemoveAnimationsTile": "Animationen entfernen",
"settingsRemoveAnimationsTitle": "Animationen entfernen",
"settingsTimeToTakeActionTile": "Zeit zum Reagieren",
"settingsTimeToTakeActionTitle": "Zeit zum Reagieren",
"settingsSectionLanguage": "Sprache & Formate",
"settingsLanguage": "Sprache",
"settingsCoordinateFormatTile": "Koordinatenformat",
"settingsCoordinateFormatTitle": "Koordinatenformat",
"settingsUnitSystemTile": "Einheiten",
"settingsUnitSystemTitle": "Einheiten",
"statsPageTitle": "Statistiken",
"statsWithGps": " {count, plural, =1{1 Element mit Standort} other{{count} Elemente mit Standort}}",
"statsTopCountries": "Top-Länder",
"statsTopPlaces": "Top-Plätze",
"statsTopTags": "Top-Tags",
"viewerOpenPanoramaButtonLabel": "ÖFFNE PANORAMA",
"viewerErrorUnknown": "Ups!",
"viewerErrorDoesNotExist": "Die Datei existiert nicht mehr.",
"viewerInfoPageTitle": "Info",
"viewerInfoBackToViewerTooltip": "Zurück zum Betrachter",
"viewerInfoUnknown": "Unbekannt",
"viewerInfoLabelTitle": "Titel",
"viewerInfoLabelDate": "Datum",
"viewerInfoLabelResolution": "Auflösung",
"viewerInfoLabelSize": "Größe",
"viewerInfoLabelUri": "URL",
"viewerInfoLabelPath": "Pfad",
"viewerInfoLabelDuration": "Dauer",
"viewerInfoLabelOwner": "Im Besitz von",
"viewerInfoLabelCoordinates": "Koordinaten",
"viewerInfoLabelAddress": "Adresse",
"mapStyleTitle": "Kartenstil",
"mapStyleTooltip": "Kartenstil auswählen",
"mapZoomInTooltip": "Vergrößern",
"mapZoomOutTooltip": "Verkleinern",
"mapPointNorthUpTooltip": "Richtung Norden aufwärts",
"mapAttributionOsmHot": "Kartendaten © [OpenStreetMap](https://www.openstreetmap.org/copyright) Mitwirkende - Kacheln von [HOT](https://www.hotosm.org/) - Gehostet von [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "Kartendaten © [OpenStreetMap](https://www.openstreetmap.org/copyright) Mitwirkende - Kacheln von [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "Auf der Karte anzeigen",
"mapEmptyRegion": "Keine Bilder in dieser Region",
"viewerInfoOpenEmbeddedFailureFeedback": "Eingebettete Daten konnten nicht extrahiert werden",
"viewerInfoOpenLinkText": "Öffnen Sie",
"viewerInfoViewXmlLinkText": "Ansicht XML",
"viewerInfoSearchFieldLabel": "Metadaten suchen",
"viewerInfoSearchEmpty": "Keine passenden Schlüssel",
"viewerInfoSearchSuggestionDate": "Datum & Uhrzeit",
"viewerInfoSearchSuggestionDescription": "Beschreibung",
"viewerInfoSearchSuggestionDimensions": "Abmessungen",
"viewerInfoSearchSuggestionResolution": "Auflösung",
"viewerInfoSearchSuggestionRights": "Rechte",
"tagEditorPageTitle": "Tags bearbeiten",
"tagEditorPageNewTagFieldLabel": "Neuer Tag",
"tagEditorPageAddTagTooltip": "Tag hinzufügen",
"tagEditorSectionRecent": "Neueste",
"panoramaEnableSensorControl": "Aktivieren der Sensorsteuerung",
"panoramaDisableSensorControl": "Sensorsteuerung deaktivieren",
"sourceViewerPageTitle": "Quelle",
"filePickerShowHiddenFiles": "Versteckte Dateien anzeigen",
"filePickerDoNotShowHiddenFiles": "Versteckte Dateien nicht anzeigen",
"filePickerOpenFrom": "Öffnen von",
"filePickerNoItems": "Keine Elemente",
"filePickerUseThisFolder": "Verwenden Sie diesen Ordner"
}

View file

@ -1,11 +1,8 @@
{
"appName": "Aves",
"@appName": {},
"welcomeMessage": "Welcome to Aves",
"@welcomeMessage": {},
"welcomeOptional": "Optional",
"welcomeTermsToggle": "I agree to the terms and conditions",
"@welcomeTermsToggle": {},
"itemCount": "{count, plural, =1{1 item} other{{count} items}}",
"@itemCount": {
"placeholders": {
@ -27,158 +24,87 @@
},
"applyButtonLabel": "APPLY",
"@applyButtonLabel": {},
"deleteButtonLabel": "DELETE",
"@deleteButtonLabel": {},
"nextButtonLabel": "NEXT",
"@nextButtonLabel": {},
"showButtonLabel": "SHOW",
"@showButtonLabel": {},
"hideButtonLabel": "HIDE",
"@hideButtonLabel": {},
"continueButtonLabel": "CONTINUE",
"@continueButtonLabel": {},
"cancelTooltip": "Cancel",
"changeTooltip": "Change",
"@changeTooltip": {},
"clearTooltip": "Clear",
"@clearTooltip": {},
"previousTooltip": "Previous",
"@previousTooltip": {},
"nextTooltip": "Next",
"@nextTooltip": {},
"showTooltip": "Show",
"@showTooltip": {},
"hideTooltip": "Hide",
"@hideTooltip": {},
"removeTooltip": "Remove",
"@removeTooltip": {},
"resetButtonTooltip": "Reset",
"@resetButtonTooltip": {},
"doubleBackExitMessage": "Tap “back” again to exit.",
"@doubleBackExitMessage": {},
"sourceStateLoading": "Loading",
"@sourceStateLoading": {},
"sourceStateCataloguing": "Cataloguing",
"@sourceStateCataloguing": {},
"sourceStateLocatingCountries": "Locating countries",
"@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Locating places",
"@sourceStateLocatingPlaces": {},
"chipActionDelete": "Delete",
"@chipActionDelete": {},
"chipActionGoToAlbumPage": "Show in Albums",
"@chipActionGoToAlbumPage": {},
"chipActionGoToCountryPage": "Show in Countries",
"@chipActionGoToCountryPage": {},
"chipActionGoToTagPage": "Show in Tags",
"@chipActionGoToTagPage": {},
"chipActionHide": "Hide",
"@chipActionHide": {},
"chipActionPin": "Pin to top",
"@chipActionPin": {},
"chipActionUnpin": "Unpin from top",
"@chipActionUnpin": {},
"chipActionRename": "Rename",
"@chipActionRename": {},
"chipActionSetCover": "Set cover",
"@chipActionSetCover": {},
"chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {},
"entryActionCopyToClipboard": "Copy to clipboard",
"@entryActionCopyToClipboard": {},
"entryActionDelete": "Delete",
"@entryActionDelete": {},
"entryActionExport": "Export",
"@entryActionExport": {},
"entryActionInfo": "Info",
"@entryActionInfo": {},
"entryActionRename": "Rename",
"@entryActionRename": {},
"entryActionRotateCCW": "Rotate counterclockwise",
"@entryActionRotateCCW": {},
"entryActionRotateCW": "Rotate clockwise",
"@entryActionRotateCW": {},
"entryActionFlip": "Flip horizontally",
"@entryActionFlip": {},
"entryActionPrint": "Print",
"@entryActionPrint": {},
"entryActionShare": "Share",
"@entryActionShare": {},
"entryActionViewSource": "View source",
"@entryActionViewSource": {},
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"@entryActionViewMotionPhotoVideo": {},
"entryActionEdit": "Edit with…",
"@entryActionEdit": {},
"entryActionOpen": "Open with…",
"@entryActionOpen": {},
"entryActionSetAs": "Set as…",
"@entryActionSetAs": {},
"entryActionOpenMap": "Show in map app…",
"@entryActionOpenMap": {},
"entryActionRotateScreen": "Rotate screen",
"@entryActionRotateScreen": {},
"entryActionAddFavourite": "Add to favourites",
"@entryActionAddFavourite": {},
"entryActionRemoveFavourite": "Remove from favourites",
"@entryActionRemoveFavourite": {},
"videoActionCaptureFrame": "Capture frame",
"@videoActionCaptureFrame": {},
"videoActionPause": "Pause",
"@videoActionPause": {},
"videoActionPlay": "Play",
"@videoActionPlay": {},
"videoActionReplay10": "Seek backward 10 seconds",
"@videoActionReplay10": {},
"videoActionSkip10": "Seek forward 10 seconds",
"@videoActionSkip10": {},
"videoActionSelectStreams": "Select tracks",
"@videoActionSelectStreams": {},
"videoActionSetSpeed": "Playback speed",
"@videoActionSetSpeed": {},
"videoActionSettings": "Settings",
"@videoActionSettings": {},
"entryInfoActionEditDate": "Edit date & time",
"@entryInfoActionEditDate": {},
"entryInfoActionEditTags": "Edit tags",
"@entryInfoActionEditTags": {},
"entryInfoActionRemoveMetadata": "Remove metadata",
"@entryInfoActionRemoveMetadata": {},
"filterFavouriteLabel": "Favourite",
"@filterFavouriteLabel": {},
"filterLocationEmptyLabel": "Unlocated",
"@filterLocationEmptyLabel": {},
"filterTagEmptyLabel": "Untagged",
"@filterTagEmptyLabel": {},
"filterTypeAnimatedLabel": "Animated",
"@filterTypeAnimatedLabel": {},
"filterTypeMotionPhotoLabel": "Motion Photo",
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panorama",
"@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Raw",
"@filterTypeRawLabel": {},
"filterTypeSphericalVideoLabel": "360° Video",
"@filterTypeSphericalVideoLabel": {},
"filterTypeGeotiffLabel": "GeoTIFF",
"@filterTypeGeotiffLabel": {},
"filterMimeImageLabel": "Image",
"@filterMimeImageLabel": {},
"filterMimeVideoLabel": "Video",
"@filterMimeVideoLabel": {},
"coordinateFormatDms": "DMS",
"@coordinateFormatDms": {},
"coordinateFormatDecimal": "Decimal degrees",
"@coordinateFormatDecimal": {},
"coordinateDms": "{coordinate} {direction}",
"@coordinateDms": {
"placeholders": {
@ -191,75 +117,44 @@
}
},
"coordinateDmsNorth": "N",
"@coordinateDmsNorth": {},
"coordinateDmsSouth": "S",
"@coordinateDmsSouth": {},
"coordinateDmsEast": "E",
"@coordinateDmsEast": {},
"coordinateDmsWest": "W",
"@coordinateDmsWest": {},
"unitSystemMetric": "Metric",
"@unitSystemMetric": {},
"unitSystemImperial": "Imperial",
"@unitSystemImperial": {},
"videoLoopModeNever": "Never",
"@videoLoopModeNever": {},
"videoLoopModeShortOnly": "Short videos only",
"@videoLoopModeShortOnly": {},
"videoLoopModeAlways": "Always",
"@videoLoopModeAlways": {},
"mapStyleGoogleNormal": "Google Maps",
"@mapStyleGoogleNormal": {},
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"@mapStyleGoogleHybrid": {},
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
"@mapStyleGoogleTerrain": {},
"mapStyleOsmHot": "Humanitarian OSM",
"@mapStyleOsmHot": {},
"mapStyleStamenToner": "Stamen Toner",
"@mapStyleStamenToner": {},
"mapStyleStamenWatercolor": "Stamen Watercolor",
"@mapStyleStamenWatercolor": {},
"nameConflictStrategyRename": "Rename",
"@nameConflictStrategyRename": {},
"nameConflictStrategyReplace": "Replace",
"@nameConflictStrategyReplace": {},
"nameConflictStrategySkip": "Skip",
"@nameConflictStrategySkip": {},
"keepScreenOnNever": "Never",
"@keepScreenOnNever": {},
"keepScreenOnViewerOnly": "Viewer page only",
"@keepScreenOnViewerOnly": {},
"keepScreenOnAlways": "Always",
"@keepScreenOnAlways": {},
"accessibilityAnimationsRemove": "Prevent screen effects",
"@accessibilityAnimationsRemove": {},
"accessibilityAnimationsKeep": "Keep screen effects",
"@accessibilityAnimationsKeep": {},
"albumTierNew": "New",
"@albumTierNew": {},
"albumTierPinned": "Pinned",
"@albumTierPinned": {},
"albumTierSpecial": "Common",
"@albumTierSpecial": {},
"albumTierApps": "Apps",
"@albumTierApps": {},
"albumTierRegular": "Others",
"@albumTierRegular": {},
"storageVolumeDescriptionFallbackPrimary": "Internal storage",
"@storageVolumeDescriptionFallbackPrimary": {},
"storageVolumeDescriptionFallbackNonPrimary": "SD card",
"@storageVolumeDescriptionFallbackNonPrimary": {},
"rootDirectoryDescription": "root directory",
"@rootDirectoryDescription": {},
"otherDirectoryDescription": "“{name}” directory",
"@otherDirectoryDescription": {
"placeholders": {
@ -269,7 +164,6 @@
}
},
"storageAccessDialogTitle": "Storage Access",
"@storageAccessDialogTitle": {},
"storageAccessDialogMessage": "Please select the {directory} of “{volume}” in the next screen to give this app access to it.",
"@storageAccessDialogMessage": {
"placeholders": {
@ -282,7 +176,6 @@
}
},
"restrictedAccessDialogTitle": "Restricted Access",
"@restrictedAccessDialogTitle": {},
"restrictedAccessDialogMessage": "This app is not allowed to modify files in the {directory} of “{volume}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.",
"@restrictedAccessDialogMessage": {
"placeholders": {
@ -295,7 +188,6 @@
}
},
"notEnoughSpaceDialogTitle": "Not Enough Space",
"@notEnoughSpaceDialogTitle": {},
"notEnoughSpaceDialogMessage": "This operation needs {neededSize} of free space on “{volume}” to complete, but there is only {freeSize} left.",
"@notEnoughSpaceDialogMessage": {
"placeholders": {
@ -312,7 +204,6 @@
},
"unsupportedTypeDialogTitle": "Unsupported Types",
"@unsupportedTypeDialogTitle": {},
"unsupportedTypeDialogMessage": "{count, plural, =1{This operation is not supported for items of the following type: {types}.} other{This operation is not supported for items of the following types: {types}.}}",
"@unsupportedTypeDialogMessage": {
"placeholders": {
@ -324,19 +215,13 @@
},
"nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.",
"@nameConflictDialogSingleSourceMessage": {},
"nameConflictDialogMultipleSourceMessage": "Some files have the same name.",
"@nameConflictDialogMultipleSourceMessage": {},
"addShortcutDialogLabel": "Shortcut label",
"@addShortcutDialogLabel": {},
"addShortcutButtonLabel": "ADD",
"@addShortcutButtonLabel": {},
"noMatchingAppDialogTitle": "No Matching App",
"@noMatchingAppDialogTitle": {},
"noMatchingAppDialogMessage": "There are no apps that can handle this.",
"@noMatchingAppDialogMessage": {},
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}",
"@deleteEntriesConfirmationDialogMessage": {
@ -352,33 +237,21 @@
}
},
"videoStartOverButtonLabel": "START OVER",
"@videoStartOverButtonLabel": {},
"videoResumeButtonLabel": "RESUME",
"@videoResumeButtonLabel": {},
"setCoverDialogTitle": "Set Cover",
"@setCoverDialogTitle": {},
"setCoverDialogLatest": "Latest item",
"@setCoverDialogLatest": {},
"setCoverDialogCustom": "Custom",
"@setCoverDialogCustom": {},
"hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?",
"@hideFilterConfirmationDialogMessage": {},
"newAlbumDialogTitle": "New Album",
"@newAlbumDialogTitle": {},
"newAlbumDialogNameLabel": "Album name",
"@newAlbumDialogNameLabel": {},
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists",
"@newAlbumDialogNameLabelAlreadyExistsHelper": {},
"newAlbumDialogStorageLabel": "Storage:",
"@newAlbumDialogStorageLabel": {},
"renameAlbumDialogLabel": "New name",
"@renameAlbumDialogLabel": {},
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
@ -394,117 +267,73 @@
},
"exportEntryDialogFormat": "Format:",
"@exportEntryDialogFormat": {},
"renameEntryDialogLabel": "New name",
"@renameEntryDialogLabel": {},
"editEntryDateDialogTitle": "Date & Time",
"@editEntryDateDialogTitle": {},
"editEntryDateDialogSet": "Set",
"@editEntryDateDialogSet": {},
"editEntryDateDialogShift": "Shift",
"@editEntryDateDialogShift": {},
"editEntryDateDialogExtractFromTitle": "Extract from title",
"@editEntryDateDialogExtractFromTitle": {},
"editEntryDateDialogClear": "Clear",
"@editEntryDateDialogClear": {},
"editEntryDateDialogFieldSelection": "Field selection",
"@editEntryDateDialogFieldSelection": {},
"editEntryDateDialogHours": "Hours",
"@editEntryDateDialogHours": {},
"editEntryDateDialogMinutes": "Minutes",
"@editEntryDateDialogMinutes": {},
"removeEntryMetadataDialogTitle": "Metadata Removal",
"@removeEntryMetadataDialogTitle": {},
"removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {},
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"videoSpeedDialogLabel": "Playback speed",
"@videoSpeedDialogLabel": {},
"videoStreamSelectionDialogVideo": "Video",
"@videoStreamSelectionDialogVideo": {},
"videoStreamSelectionDialogAudio": "Audio",
"@videoStreamSelectionDialogAudio": {},
"videoStreamSelectionDialogText": "Subtitles",
"@videoStreamSelectionDialogText": {},
"videoStreamSelectionDialogOff": "Off",
"@videoStreamSelectionDialogOff": {},
"videoStreamSelectionDialogTrack": "Track",
"@videoStreamSelectionDialogTrack": {},
"videoStreamSelectionDialogNoSelection": "There are no other tracks.",
"@videoStreamSelectionDialogNoSelection": {},
"genericSuccessFeedback": "Done!",
"@genericSuccessFeedback": {},
"genericFailureFeedback": "Failed",
"@genericFailureFeedback": {},
"menuActionSort": "Sort",
"@menuActionSort": {},
"menuActionGroup": "Group",
"@menuActionGroup": {},
"menuActionConfigureView": "View",
"menuActionSelect": "Select",
"@menuActionSelect": {},
"menuActionSelectAll": "Select all",
"@menuActionSelectAll": {},
"menuActionSelectNone": "Select none",
"@menuActionSelectNone": {},
"menuActionMap": "Map",
"@menuActionMap": {},
"menuActionStats": "Stats",
"@menuActionStats": {},
"viewDialogTabSort": "Sort",
"viewDialogTabGroup": "Group",
"viewDialogTabLayout": "Layout",
"tileLayoutGrid": "Grid",
"tileLayoutList": "List",
"aboutPageTitle": "About",
"@aboutPageTitle": {},
"aboutLinkSources": "Sources",
"@aboutLinkSources": {},
"aboutLinkLicense": "License",
"@aboutLinkLicense": {},
"aboutLinkPolicy": "Privacy Policy",
"@aboutLinkPolicy": {},
"aboutUpdate": "New Version Available",
"@aboutUpdate": {},
"aboutUpdateLinks1": "A new version of Aves is available on",
"@aboutUpdateLinks1": {},
"aboutUpdateLinks2": "and",
"@aboutUpdateLinks2": {},
"aboutUpdateLinks3": ".",
"@aboutUpdateLinks3": {},
"aboutUpdateGitHub": "GitHub",
"@aboutUpdateGitHub": {},
"aboutUpdateGooglePlay": "Google Play",
"@aboutUpdateGooglePlay": {},
"aboutBug": "Bug Report",
"@aboutBug": {},
"aboutBugSaveLogInstruction": "Save app logs to a file",
"@aboutBugSaveLogInstruction": {},
"aboutBugSaveLogButton": "Save",
"@aboutBugSaveLogButton": {},
"aboutBugCopyInfoInstruction": "Copy system information",
"@aboutBugCopyInfoInstruction": {},
"aboutBugCopyInfoButton": "Copy",
"@aboutBugCopyInfoButton": {},
"aboutBugReportInstruction": "Report on GitHub with the logs and system information",
"@aboutBugReportInstruction": {},
"aboutBugReportButton": "Report",
"@aboutBugReportButton": {},
"aboutCredits": "Credits",
"@aboutCredits": {},
"aboutCreditsWorldAtlas1": "This app uses a TopoJSON file from",
"@aboutCreditsWorldAtlas1": {},
"aboutCreditsWorldAtlas2": "under ISC License.",
"@aboutCreditsWorldAtlas2": {},
"aboutCreditsTranslators": "Translators:",
"@aboutCreditsTranslators": {},
"aboutCreditsTranslatorLine": "{language}: {names}",
"@aboutCreditsTranslatorLine": {
"placeholders": {
@ -518,27 +347,17 @@
},
"aboutLicenses": "Open-Source Licenses",
"@aboutLicenses": {},
"aboutLicensesBanner": "This app uses the following open-source packages and libraries.",
"@aboutLicensesBanner": {},
"aboutLicensesAndroidLibraries": "Android Libraries",
"@aboutLicensesAndroidLibraries": {},
"aboutLicensesFlutterPlugins": "Flutter Plugins",
"@aboutLicensesFlutterPlugins": {},
"aboutLicensesFlutterPackages": "Flutter Packages",
"@aboutLicensesFlutterPackages": {},
"aboutLicensesDartPackages": "Dart Packages",
"@aboutLicensesDartPackages": {},
"aboutLicensesShowAllButtonLabel": "Show All Licenses",
"@aboutLicensesShowAllButtonLabel": {},
"policyPageTitle": "Privacy Policy",
"@policyPageTitle": {},
"collectionPageTitle": "Collection",
"@collectionPageTitle": {},
"collectionPickPageTitle": "Pick",
"@collectionPickPageTitle": {},
"collectionSelectionPageTitle": "{count, plural, =0{Select items} =1{1 item} other{{count} items}}",
"@collectionSelectionPageTitle": {
"placeholders": {
@ -547,51 +366,28 @@
},
"collectionActionShowTitleSearch": "Show title filter",
"@collectionActionShowTitleSearch": {},
"collectionActionHideTitleSearch": "Hide title filter",
"@collectionActionHideTitleSearch": {},
"collectionActionAddShortcut": "Add shortcut",
"@collectionActionAddShortcut": {},
"collectionActionCopy": "Copy to album",
"@collectionActionCopy": {},
"collectionActionMove": "Move to album",
"@collectionActionMove": {},
"collectionActionRescan": "Rescan",
"@collectionActionRescan": {},
"collectionActionEdit": "Edit",
"@collectionActionEdit": {},
"collectionSearchTitlesHintText": "Search titles",
"@collectionSearchTitlesHintText": {},
"collectionSortTitle": "Sort",
"@collectionSortTitle": {},
"collectionSortDate": "By date",
"@collectionSortDate": {},
"collectionSortSize": "By size",
"@collectionSortSize": {},
"collectionSortName": "By album & file name",
"@collectionSortName": {},
"collectionGroupTitle": "Group",
"@collectionGroupTitle": {},
"collectionGroupAlbum": "By album",
"@collectionGroupAlbum": {},
"collectionGroupMonth": "By month",
"@collectionGroupMonth": {},
"collectionGroupDay": "By day",
"@collectionGroupDay": {},
"collectionGroupNone": "Do not group",
"@collectionGroupNone": {},
"sectionUnknown": "Unknown",
"@sectionUnknown": {},
"dateToday": "Today",
"@dateToday": {},
"dateYesterday": "Yesterday",
"@dateYesterday": {},
"dateThisMonth": "This month",
"@dateThisMonth": {},
"collectionDeleteFailureFeedback": "{count, plural, =1{Failed to delete 1 item} other{Failed to delete {count} items}}",
"@collectionDeleteFailureFeedback": {
"placeholders": {
@ -642,324 +438,178 @@
},
"collectionEmptyFavourites": "No favourites",
"@collectionEmptyFavourites": {},
"collectionEmptyVideos": "No videos",
"@collectionEmptyVideos": {},
"collectionEmptyImages": "No images",
"@collectionEmptyImages": {},
"collectionSelectSectionTooltip": "Select section",
"@collectionSelectSectionTooltip": {},
"collectionDeselectSectionTooltip": "Deselect section",
"@collectionDeselectSectionTooltip": {},
"drawerCollectionAll": "All collection",
"@drawerCollectionAll": {},
"drawerCollectionFavourites": "Favourites",
"@drawerCollectionFavourites": {},
"drawerCollectionImages": "Images",
"@drawerCollectionImages": {},
"drawerCollectionVideos": "Videos",
"@drawerCollectionVideos": {},
"drawerCollectionAnimated": "Animated",
"@drawerCollectionAnimated": {},
"drawerCollectionMotionPhotos": "Motion photos",
"@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoramas",
"@drawerCollectionPanoramas": {},
"drawerCollectionRaws": "Raw photos",
"@drawerCollectionRaws": {},
"drawerCollectionSphericalVideos": "360° Videos",
"@drawerCollectionSphericalVideos": {},
"chipSortTitle": "Sort",
"@chipSortTitle": {},
"chipSortDate": "By date",
"@chipSortDate": {},
"chipSortName": "By name",
"@chipSortName": {},
"chipSortCount": "By item count",
"@chipSortCount": {},
"albumGroupTitle": "Group",
"@albumGroupTitle": {},
"albumGroupTier": "By tier",
"@albumGroupTier": {},
"albumGroupVolume": "By storage volume",
"@albumGroupVolume": {},
"albumGroupNone": "Do not group",
"@albumGroupNone": {},
"albumPickPageTitleCopy": "Copy to Album",
"@albumPickPageTitleCopy": {},
"albumPickPageTitleExport": "Export to Album",
"@albumPickPageTitleExport": {},
"albumPickPageTitleMove": "Move to Album",
"@albumPickPageTitleMove": {},
"albumPickPageTitlePick": "Pick Album",
"@albumPickPageTitlePick": {},
"albumCamera": "Camera",
"@albumCamera": {},
"albumDownload": "Download",
"@albumDownload": {},
"albumScreenshots": "Screenshots",
"@albumScreenshots": {},
"albumScreenRecordings": "Screen recordings",
"@albumScreenRecordings": {},
"albumVideoCaptures": "Video Captures",
"@albumVideoCaptures": {},
"albumPageTitle": "Albums",
"@albumPageTitle": {},
"albumEmpty": "No albums",
"@albumEmpty": {},
"createAlbumTooltip": "Create album",
"@createAlbumTooltip": {},
"createAlbumButtonLabel": "CREATE",
"@createAlbumButtonLabel": {},
"newFilterBanner": "new",
"@newFilterBanner": {},
"countryPageTitle": "Countries",
"@countryPageTitle": {},
"countryEmpty": "No countries",
"@countryEmpty": {},
"tagPageTitle": "Tags",
"@tagPageTitle": {},
"tagEmpty": "No tags",
"@tagEmpty": {},
"searchCollectionFieldHint": "Search collection",
"@searchCollectionFieldHint": {},
"searchSectionRecent": "Recent",
"@searchSectionRecent": {},
"searchSectionAlbums": "Albums",
"@searchSectionAlbums": {},
"searchSectionCountries": "Countries",
"@searchSectionCountries": {},
"searchSectionPlaces": "Places",
"@searchSectionPlaces": {},
"searchSectionTags": "Tags",
"@searchSectionTags": {},
"settingsPageTitle": "Settings",
"@settingsPageTitle": {},
"settingsSystemDefault": "System",
"@settingsSystemDefault": {},
"settingsDefault": "Default",
"@settingsDefault": {},
"settingsActionExport": "Export",
"@settingsActionExport": {},
"settingsActionImport": "Import",
"@settingsActionImport": {},
"settingsSectionNavigation": "Navigation",
"@settingsSectionNavigation": {},
"settingsHome": "Home",
"@settingsHome": {},
"settingsKeepScreenOnTile": "Keep screen on",
"@settingsKeepScreenOnTile": {},
"settingsKeepScreenOnTitle": "Keep Screen On",
"@settingsKeepScreenOnTitle": {},
"settingsDoubleBackExit": "Tap “back” twice to exit",
"@settingsDoubleBackExit": {},
"settingsNavigationDrawerTile": "Navigation menu",
"@settingsNavigationDrawerTile": {},
"settingsNavigationDrawerEditorTitle": "Navigation Menu",
"@settingsNavigationDrawerEditorTitle": {},
"settingsNavigationDrawerBanner": "Touch and hold to move and reorder menu items.",
"@settingsNavigationDrawerBanner": {},
"settingsNavigationDrawerTabTypes": "Types",
"@settingsNavigationDrawerTabTypes": {},
"settingsNavigationDrawerTabAlbums": "Albums",
"@settingsNavigationDrawerTabAlbums": {},
"settingsNavigationDrawerTabPages": "Pages",
"@settingsNavigationDrawerTabPages": {},
"settingsNavigationDrawerAddAlbum": "Add album",
"@settingsNavigationDrawerAddAlbum": {},
"settingsSectionThumbnails": "Thumbnails",
"@settingsSectionThumbnails": {},
"settingsThumbnailShowLocationIcon": "Show location icon",
"@settingsThumbnailShowLocationIcon": {},
"settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon",
"@settingsThumbnailShowMotionPhotoIcon": {},
"settingsThumbnailShowRawIcon": "Show raw icon",
"@settingsThumbnailShowRawIcon": {},
"settingsThumbnailShowVideoDuration": "Show video duration",
"@settingsThumbnailShowVideoDuration": {},
"settingsCollectionQuickActionsTile": "Quick actions",
"@settingsCollectionQuickActionsTile": {},
"settingsCollectionQuickActionEditorTitle": "Quick Actions",
"@settingsCollectionQuickActionEditorTitle": {},
"settingsCollectionQuickActionTabBrowsing": "Browsing",
"@settingsCollectionQuickActionTabBrowsing": {},
"settingsCollectionQuickActionTabSelecting": "Selecting",
"@settingsCollectionQuickActionTabSelecting": {},
"settingsCollectionBrowsingQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when browsing items.",
"@settingsCollectionBrowsingQuickActionEditorBanner": {},
"settingsCollectionSelectionQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed when selecting items.",
"@settingsCollectionSelectionQuickActionEditorBanner": {},
"settingsSectionViewer": "Viewer",
"@settingsSectionViewer": {},
"settingsViewerUseCutout": "Use cutout area",
"@settingsViewerUseCutout": {},
"settingsViewerMaximumBrightness": "Maximum brightness",
"@settingsViewerMaximumBrightness": {},
"settingsMotionPhotoAutoPlay": "Auto play motion photos",
"settingsImageBackground": "Image background",
"@settingsImageBackground": {},
"settingsViewerQuickActionsTile": "Quick actions",
"@settingsViewerQuickActionsTile": {},
"settingsViewerQuickActionEditorTitle": "Quick Actions",
"@settingsViewerQuickActionEditorTitle": {},
"settingsViewerQuickActionEditorBanner": "Touch and hold to move buttons and select which actions are displayed in the viewer.",
"@settingsViewerQuickActionEditorBanner": {},
"settingsViewerQuickActionEditorDisplayedButtons": "Displayed Buttons",
"@settingsViewerQuickActionEditorDisplayedButtons": {},
"settingsViewerQuickActionEditorAvailableButtons": "Available Buttons",
"@settingsViewerQuickActionEditorAvailableButtons": {},
"settingsViewerQuickActionEmpty": "No buttons",
"@settingsViewerQuickActionEmpty": {},
"settingsViewerOverlayTile": "Overlay",
"@settingsViewerOverlayTile": {},
"settingsViewerOverlayTitle": "Overlay",
"@settingsViewerOverlayTitle": {},
"settingsViewerShowOverlayOnOpening": "Show on opening",
"@settingsViewerShowOverlayOnOpening": {},
"settingsViewerShowMinimap": "Show minimap",
"@settingsViewerShowMinimap": {},
"settingsViewerShowInformation": "Show information",
"@settingsViewerShowInformation": {},
"settingsViewerShowInformationSubtitle": "Show title, date, location, etc.",
"@settingsViewerShowInformationSubtitle": {},
"settingsViewerShowShootingDetails": "Show shooting details",
"@settingsViewerShowShootingDetails": {},
"settingsViewerEnableOverlayBlurEffect": "Blur effect",
"@settingsViewerEnableOverlayBlurEffect": {},
"settingsVideoPageTitle": "Video Settings",
"@settingsVideoPageTitle": {},
"settingsSectionVideo": "Video",
"@settingsSectionVideo": {},
"settingsVideoShowVideos": "Show videos",
"@settingsVideoShowVideos": {},
"settingsVideoEnableHardwareAcceleration": "Hardware acceleration",
"@settingsVideoEnableHardwareAcceleration": {},
"settingsVideoEnableAutoPlay": "Auto play",
"@settingsVideoEnableAutoPlay": {},
"settingsVideoLoopModeTile": "Loop mode",
"@settingsVideoLoopModeTile": {},
"settingsVideoLoopModeTitle": "Loop Mode",
"@settingsVideoLoopModeTitle": {},
"settingsVideoQuickActionsTile": "Quick actions for videos",
"@settingsVideoQuickActionsTile": {},
"settingsVideoQuickActionEditorTitle": "Quick Actions",
"@settingsVideoQuickActionEditorTitle": {},
"settingsSubtitleThemeTile": "Subtitles",
"@settingsSubtitleThemeTile": {},
"settingsSubtitleThemeTitle": "Subtitles",
"@settingsSubtitleThemeTitle": {},
"settingsSubtitleThemeSample": "This is a sample.",
"@settingsSubtitleThemeSample": {},
"settingsSubtitleThemeTextAlignmentTile": "Text alignment",
"@settingsSubtitleThemeTextAlignmentTile": {},
"settingsSubtitleThemeTextAlignmentTitle": "Text Alignment",
"@settingsSubtitleThemeTextAlignmentTitle": {},
"settingsSubtitleThemeTextSize": "Text size",
"@settingsSubtitleThemeTextSize": {},
"settingsSubtitleThemeShowOutline": "Show outline and shadow",
"@settingsSubtitleThemeShowOutline": {},
"settingsSubtitleThemeTextColor": "Text color",
"@settingsSubtitleThemeTextColor": {},
"settingsSubtitleThemeTextOpacity": "Text opacity",
"@settingsSubtitleThemeTextOpacity": {},
"settingsSubtitleThemeBackgroundColor": "Background color",
"@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy",
"@settingsSectionPrivacy": {},
"settingsAllowInstalledAppAccess": "Allow access to app inventory",
"@settingsAllowInstalledAppAccess": {},
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",
"@settingsAllowInstalledAppAccessSubtitle": {},
"settingsAllowErrorReporting": "Allow anonymous error reporting",
"@settingsAllowErrorReporting": {},
"settingsSaveSearchHistory": "Save search history",
"@settingsSaveSearchHistory": {},
"settingsHiddenItemsTile": "Hidden items",
"@settingsHiddenItemsTile": {},
"settingsHiddenItemsTitle": "Hidden Items",
"@settingsHiddenItemsTitle": {},
"settingsHiddenFiltersTitle": "Hidden Filters",
"@settingsHiddenFiltersTitle": {},
"settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.",
"@settingsHiddenFiltersBanner": {},
"settingsHiddenFiltersEmpty": "No hidden filters",
"@settingsHiddenFiltersEmpty": {},
"settingsHiddenPathsTitle": "Hidden Paths",
"@settingsHiddenPathsTitle": {},
"settingsHiddenPathsBanner": "Photos and videos in these folders, or any of their subfolders, will not appear in your collection.",
"@settingsHiddenPathsBanner": {},
"addPathTooltip": "Add path",
"@addPathTooltip": {},
"settingsStorageAccessTile": "Storage access",
"@settingsStorageAccessTile": {},
"settingsStorageAccessTitle": "Storage Access",
"@settingsStorageAccessTitle": {},
"settingsStorageAccessBanner": "Some directories require an explicit access grant to modify files in them. You can review here directories to which you previously gave access.",
"@settingsStorageAccessBanner": {},
"settingsStorageAccessEmpty": "No access grants",
"@settingsStorageAccessEmpty": {},
"settingsStorageAccessRevokeTooltip": "Revoke",
"@settingsStorageAccessRevokeTooltip": {},
"settingsSectionAccessibility": "Accessibility",
"@settingsSectionAccessibility": {},
"settingsRemoveAnimationsTile": "Remove animations",
"@settingsRemoveAnimationsTile": {},
"settingsRemoveAnimationsTitle": "Remove Animations",
"@settingsRemoveAnimationsTitle": {},
"settingsTimeToTakeActionTile": "Time to take action",
"@settingsTimeToTakeActionTile": {},
"settingsTimeToTakeActionTitle": "Time to Take Action",
"@settingsTimeToTakeActionTitle": {},
"settingsSectionLanguage": "Language & Formats",
"@settingsSectionLanguage": {},
"settingsLanguage": "Language",
"@settingsLanguage": {},
"settingsCoordinateFormatTile": "Coordinate format",
"@settingsCoordinateFormatTile": {},
"settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {},
"settingsUnitSystemTile": "Units",
"@settingsUnitSystemTile": {},
"settingsUnitSystemTitle": "Units",
"@settingsUnitSystemTitle": {},
"statsPageTitle": "Stats",
"@statsPageTitle": {},
"statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}",
"@statsWithGps": {
"placeholders": {
@ -967,113 +617,64 @@
}
},
"statsTopCountries": "Top Countries",
"@statsTopCountries": {},
"statsTopPlaces": "Top Places",
"@statsTopPlaces": {},
"statsTopTags": "Top Tags",
"@statsTopTags": {},
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
"@viewerOpenPanoramaButtonLabel": {},
"viewerErrorUnknown": "Oops!",
"@viewerErrorUnknown": {},
"viewerErrorDoesNotExist": "The file no longer exists.",
"@viewerErrorDoesNotExist": {},
"viewerInfoPageTitle": "Info",
"@viewerInfoPageTitle": {},
"viewerInfoBackToViewerTooltip": "Back to viewer",
"@viewerInfoBackToViewerTooltip": {},
"viewerInfoUnknown": "unknown",
"@viewerInfoUnknown": {},
"viewerInfoLabelTitle": "Title",
"@viewerInfoLabelTitle": {},
"viewerInfoLabelDate": "Date",
"@viewerInfoLabelDate": {},
"viewerInfoLabelResolution": "Resolution",
"@viewerInfoLabelResolution": {},
"viewerInfoLabelSize": "Size",
"@viewerInfoLabelSize": {},
"viewerInfoLabelUri": "URI",
"@viewerInfoLabelUri": {},
"viewerInfoLabelPath": "Path",
"@viewerInfoLabelPath": {},
"viewerInfoLabelDuration": "Duration",
"@viewerInfoLabelDuration": {},
"viewerInfoLabelOwner": "Owned by",
"@viewerInfoLabelOwner": {},
"viewerInfoLabelCoordinates": "Coordinates",
"@viewerInfoLabelCoordinates": {},
"viewerInfoLabelAddress": "Address",
"@viewerInfoLabelAddress": {},
"mapStyleTitle": "Map Style",
"@mapStyleTitle": {},
"mapStyleTooltip": "Select map style",
"@mapStyleTooltip": {},
"mapZoomInTooltip": "Zoom in",
"@mapZoomInTooltip": {},
"mapZoomOutTooltip": "Zoom out",
"@mapZoomOutTooltip": {},
"mapPointNorthUpTooltip": "Point north up",
"@mapPointNorthUpTooltip": {},
"mapAttributionOsmHot": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [HOT](https://www.hotosm.org/) • Hosted by [OSM France](https://openstreetmap.fr/)",
"@mapAttributionOsmHot": {},
"mapAttributionStamen": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"@mapAttributionStamen": {},
"openMapPageTooltip": "View on Map page",
"@openMapPageTooltip": {},
"mapEmptyRegion": "No images in this region",
"@mapEmpty": {},
"viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data",
"@viewerInfoOpenEmbeddedFailureFeedback": {},
"viewerInfoOpenLinkText": "Open",
"@viewerInfoOpenLinkText": {},
"viewerInfoViewXmlLinkText": "View XML",
"@viewerInfoViewXmlLinkText": {},
"viewerInfoSearchFieldLabel": "Search metadata",
"@viewerInfoSearchFieldLabel": {},
"viewerInfoSearchEmpty": "No matching keys",
"@viewerInfoSearchEmpty": {},
"viewerInfoSearchSuggestionDate": "Date & time",
"@viewerInfoSearchSuggestionDate": {},
"viewerInfoSearchSuggestionDescription": "Description",
"@viewerInfoSearchSuggestionDescription": {},
"viewerInfoSearchSuggestionDimensions": "Dimensions",
"@viewerInfoSearchSuggestionDimensions": {},
"viewerInfoSearchSuggestionResolution": "Resolution",
"@viewerInfoSearchSuggestionResolution": {},
"viewerInfoSearchSuggestionRights": "Rights",
"@viewerInfoSearchSuggestionRights": {},
"tagEditorPageTitle": "Edit Tags",
"@tagEditorPageTitle": {},
"tagEditorPageNewTagFieldLabel": "New tag",
"@tagEditorPageNewTagFieldLabel": {},
"tagEditorPageAddTagTooltip": "Add tag",
"@tagEditorPageAddTagTooltip": {},
"tagEditorSectionRecent": "Recent",
"@tagEditorSectionRecent": {},
"panoramaEnableSensorControl": "Enable sensor control",
"@panoramaEnableSensorControl": {},
"panoramaDisableSensorControl": "Disable sensor control",
"@panoramaDisableSensorControl": {},
"sourceViewerPageTitle": "Source",
"@sourceViewerPageTitle": {},
"filePickerShowHiddenFiles": "Show hidden files",
"@filePickerShowHiddenFiles": {},
"filePickerDoNotShowHiddenFiles": "Dont show hidden files",
"@filePickerDoNotShowHiddenFiles": {},
"filePickerOpenFrom": "Open from",
"@filePickerOpenFrom": {},
"filePickerNoItems": "No items",
"@filePickerNoItems": {},
"filePickerUseThisFolder": "Use this folder",
"@filePickerUseThisFolder": {}
}

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "MASQUER",
"continueButtonLabel": "CONTINUER",
"cancelTooltip": "Annuler",
"changeTooltip": "Modifier",
"clearTooltip": "Effacer",
"previousTooltip": "Précédent",
@ -202,14 +203,20 @@
"genericSuccessFeedback": "Succès !",
"genericFailureFeedback": "Échec",
"menuActionSort": "Trier",
"menuActionGroup": "Grouper",
"menuActionConfigureView": "Vue",
"menuActionSelect": "Sélectionner",
"menuActionSelectAll": "Tout sélectionner",
"menuActionSelectNone": "Tout désélectionner",
"menuActionMap": "Carte",
"menuActionStats": "Statistiques",
"viewDialogTabSort": "Tri",
"viewDialogTabGroup": "Groupes",
"viewDialogTabLayout": "Vue",
"tileLayoutGrid": "Grille",
"tileLayoutList": "Liste",
"aboutPageTitle": "À propos",
"aboutLinkSources": "Sources",
"aboutLinkLicense": "Licence",
@ -260,12 +267,10 @@
"collectionSearchTitlesHintText": "Recherche de titres",
"collectionSortTitle": "Trier",
"collectionSortDate": "par date",
"collectionSortSize": "par taille",
"collectionSortName": "alphabétiquement",
"collectionSortName": "alphabétique",
"collectionGroupTitle": "Grouper",
"collectionGroupAlbum": "par album",
"collectionGroupMonth": "par mois",
"collectionGroupDay": "par jour",
@ -301,12 +306,10 @@
"drawerCollectionRaws": "Photos Raw",
"drawerCollectionSphericalVideos": "Vidéos à 360°",
"chipSortTitle": "Trier",
"chipSortDate": "par date",
"chipSortName": "par nom",
"chipSortName": "alphabétique",
"chipSortCount": "par nombre déléments",
"albumGroupTitle": "Grouper",
"albumGroupTier": "par importance",
"albumGroupVolume": "par volume de stockage",
"albumGroupNone": "ne pas grouper",
@ -378,6 +381,7 @@
"settingsSectionViewer": "Visionneuse",
"settingsViewerUseCutout": "Utiliser la zone dencoche",
"settingsViewerMaximumBrightness": "Luminosité maximale",
"settingsMotionPhotoAutoPlay": "Lecture automatique des photos animées",
"settingsImageBackground": "Arrière-plan de limage",
"settingsViewerQuickActionsTile": "Actions rapides",
@ -513,7 +517,6 @@
"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",

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "숨기기",
"continueButtonLabel": "다음",
"cancelTooltip": "취소",
"changeTooltip": "변경",
"clearTooltip": "초기화",
"previousTooltip": "이전",
@ -202,14 +203,20 @@
"genericSuccessFeedback": "정상 처리됐습니다",
"genericFailureFeedback": "오류가 발생했습니다",
"menuActionSort": "정렬",
"menuActionGroup": "묶음",
"menuActionConfigureView": "보기 설정",
"menuActionSelect": "선택",
"menuActionSelectAll": "모두 선택",
"menuActionSelectNone": "모두 해제",
"menuActionMap": "지도",
"menuActionStats": "통계",
"viewDialogTabSort": "정렬",
"viewDialogTabGroup": "묶음",
"viewDialogTabLayout": "배치",
"tileLayoutGrid": "바둑판",
"tileLayoutList": "목록",
"aboutPageTitle": "앱 정보",
"aboutLinkSources": "소스 코드",
"aboutLinkLicense": "라이선스",
@ -260,12 +267,10 @@
"collectionSearchTitlesHintText": "제목 검색",
"collectionSortTitle": "정렬",
"collectionSortDate": "날짜",
"collectionSortSize": "크기",
"collectionSortName": "이름",
"collectionGroupTitle": "묶음",
"collectionGroupAlbum": "앨범별로",
"collectionGroupMonth": "월별로",
"collectionGroupDay": "날짜별로",
@ -301,12 +306,10 @@
"drawerCollectionRaws": "Raw 이미지",
"drawerCollectionSphericalVideos": "360° 동영상",
"chipSortTitle": "정렬",
"chipSortDate": "날짜",
"chipSortName": "이름",
"chipSortCount": "항목수",
"albumGroupTitle": "묶음",
"albumGroupTier": "단계별로",
"albumGroupVolume": "저장공간별로",
"albumGroupNone": "묶음 없음",
@ -378,6 +381,7 @@
"settingsSectionViewer": "뷰어",
"settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsViewerMaximumBrightness": "최대 밝기",
"settingsMotionPhotoAutoPlay": "모션 포토 자동 재생",
"settingsImageBackground": "이미지 배경",
"settingsViewerQuickActionsTile": "빠른 작업",

View file

@ -15,6 +15,7 @@
"hideButtonLabel": "СКРЫТЬ",
"continueButtonLabel": "ПРОДОЛЖИТЬ",
"cancelTooltip": "Отмена",
"changeTooltip": "Изменить",
"clearTooltip": "Очистить",
"previousTooltip": "Предыдущий",
@ -22,6 +23,7 @@
"showTooltip": "Показать",
"hideTooltip": "Скрыть",
"removeTooltip": "Удалить",
"resetButtonTooltip": "Сбросить",
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
@ -71,6 +73,7 @@
"videoActionSettings": "Настройки",
"entryInfoActionEditDate": "Изменить дату и время",
"entryInfoActionEditTags": "Изменить теги",
"entryInfoActionRemoveMetadata": "Удалить метаданные",
"filterFavouriteLabel": "Избранное",
@ -126,10 +129,10 @@
"storageVolumeDescriptionFallbackPrimary": "Внутренняя память",
"storageVolumeDescriptionFallbackNonPrimary": "SD-карта",
"rootDirectoryDescription": "корень",
"otherDirectoryDescription": "“{name}” каталог",
"rootDirectoryDescription": "корневой каталог",
"otherDirectoryDescription": "каталог «{name}»",
"storageAccessDialogTitle": "Доступ к хранилищу",
"storageAccessDialogMessage": "Пожалуйста, выберите каталог {directory} на накопителе «{volume}» на следующем экране, чтобы предоставить этому приложению доступ к нему.",
"storageAccessDialogMessage": "Пожалуйста, выберите {directory} на накопителе «{volume}» на следующем экране, чтобы предоставить этому приложению доступ к нему.",
"restrictedAccessDialogTitle": "Ограниченный доступ",
"restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.",
"notEnoughSpaceDialogTitle": "Недостаточно свободного места.",
@ -200,14 +203,20 @@
"genericSuccessFeedback": "Выполнено!",
"genericFailureFeedback": "Не удалось",
"menuActionSort": "Сортировка",
"menuActionGroup": "Группировка",
"menuActionConfigureView": "Вид",
"menuActionSelect": "Выбрать",
"menuActionSelectAll": "Выбрать все",
"menuActionSelectNone": "Снять выделение",
"menuActionMap": "Карта",
"menuActionStats": "Статистика",
"viewDialogTabSort": "Сортировка",
"viewDialogTabGroup": "Группировка",
"viewDialogTabLayout": "Макет",
"tileLayoutGrid": "Сетка",
"tileLayoutList": "Список",
"aboutPageTitle": "О нас",
"aboutLinkSources": "Исходники",
"aboutLinkLicense": "Лицензия",
@ -258,12 +267,10 @@
"collectionSearchTitlesHintText": "Поиск заголовков",
"collectionSortTitle": "Сортировка",
"collectionSortDate": "По дате",
"collectionSortSize": "По размеру",
"collectionSortName": "По имени альбома и файла",
"collectionGroupTitle": "Группировка",
"collectionGroupAlbum": "По альбому",
"collectionGroupMonth": "По месяцу",
"collectionGroupDay": "По дню",
@ -299,12 +306,10 @@
"drawerCollectionRaws": "RAW",
"drawerCollectionSphericalVideos": "360° видео",
"chipSortTitle": "Сортировка",
"chipSortDate": "По дате",
"chipSortName": "По названию",
"chipSortCount": "По количеству объектов",
"albumGroupTitle": "Группировка",
"albumGroupTier": "По уровню",
"albumGroupVolume": "По накопителю",
"albumGroupNone": "Не группировать",
@ -375,6 +380,8 @@
"settingsSectionViewer": "Просмотрщик",
"settingsViewerUseCutout": "Использовать область выреза",
"settingsViewerMaximumBrightness": "Максимальная яркость",
"settingsMotionPhotoAutoPlay": "Автовоспроизведение «Живых фото»",
"settingsImageBackground": "Фон изображения",
"settingsViewerQuickActionsTile": "Быстрые действия",
@ -501,6 +508,9 @@
"viewerInfoSearchSuggestionResolution": "Разрешение",
"viewerInfoSearchSuggestionRights": "Права",
"tagEditorPageTitle": "Изменить теги",
"tagEditorPageNewTagFieldLabel": "Новый тег",
"tagEditorPageAddTagTooltip": "Добавить тег",
"tagEditorSectionRecent": "Недавние",
"panoramaEnableSensorControl": "Включить сенсорное управление",

1
lib/l10n/l10n.dart Normal file
View file

@ -0,0 +1 @@
export 'package:flutter_gen/gen_l10n/app_localizations.dart';

View file

@ -4,8 +4,7 @@ import 'package:flutter/material.dart';
enum ChipSetAction {
// general
sort,
group,
configureView,
select,
selectAll,
selectNone,
@ -27,8 +26,7 @@ enum ChipSetAction {
class ChipSetActions {
static const general = [
ChipSetAction.sort,
ChipSetAction.group,
ChipSetAction.configureView,
ChipSetAction.select,
ChipSetAction.selectAll,
ChipSetAction.selectNone,
@ -57,10 +55,8 @@ extension ExtraChipSetAction on ChipSetAction {
String getText(BuildContext context) {
switch (this) {
// general
case ChipSetAction.sort:
return context.l10n.menuActionSort;
case ChipSetAction.group:
return context.l10n.menuActionGroup;
case ChipSetAction.configureView:
return context.l10n.menuActionConfigureView;
case ChipSetAction.select:
return context.l10n.menuActionSelect;
case ChipSetAction.selectAll:
@ -101,10 +97,8 @@ extension ExtraChipSetAction on ChipSetAction {
IconData _getIconData() {
switch (this) {
// general
case ChipSetAction.sort:
return AIcons.sort;
case ChipSetAction.group:
return AIcons.group;
case ChipSetAction.configureView:
return AIcons.view;
case ChipSetAction.select:
return AIcons.select;
case ChipSetAction.selectAll:

View file

@ -4,8 +4,7 @@ import 'package:flutter/material.dart';
enum EntrySetAction {
// general
sort,
group,
configureView,
select,
selectAll,
selectNone,
@ -32,8 +31,7 @@ enum EntrySetAction {
class EntrySetActions {
static const general = [
EntrySetAction.sort,
EntrySetAction.group,
EntrySetAction.configureView,
EntrySetAction.select,
EntrySetAction.selectAll,
EntrySetAction.selectNone,
@ -63,10 +61,8 @@ extension ExtraEntrySetAction on EntrySetAction {
String getText(BuildContext context) {
switch (this) {
// general
case EntrySetAction.sort:
return context.l10n.menuActionSort;
case EntrySetAction.group:
return context.l10n.menuActionGroup;
case EntrySetAction.configureView:
return context.l10n.menuActionConfigureView;
case EntrySetAction.select:
return context.l10n.menuActionSelect;
case EntrySetAction.selectAll:
@ -119,10 +115,8 @@ extension ExtraEntrySetAction on EntrySetAction {
IconData _getIconData() {
switch (this) {
// general
case EntrySetAction.sort:
return AIcons.sort;
case EntrySetAction.group:
return AIcons.group;
case EntrySetAction.configureView:
return AIcons.view;
case EntrySetAction.select:
return AIcons.select;
case EntrySetAction.selectAll:

View file

@ -3,7 +3,6 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:github/github.dart';
import 'package:google_api_availability/google_api_availability.dart';
import 'package:package_info_plus/package_info_plus.dart';

View file

@ -703,8 +703,8 @@ class AvesEntry {
Future<bool> delete() {
final completer = Completer<bool>();
mediaFileService.delete([this]).listen(
(event) => completer.complete(event.success),
mediaFileService.delete(entries: {this}).listen(
(event) => completer.complete(event.success && !event.skipped),
onError: completer.completeError,
onDone: () {
if (!completer.isCompleted) {

View file

@ -1,5 +1,4 @@
import 'dart:math';
import 'dart:ui';
import 'package:aves/image_providers/region_provider.dart';
import 'package:aves/image_providers/thumbnail_provider.dart';

View file

@ -44,12 +44,11 @@ class AlbumFilter extends CollectionFilter {
String getTooltip(BuildContext context) => album;
@override
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
return IconUtils.getAlbumIcon(
context: context,
albumPath: album,
size: size,
embossed: embossed,
) ??
(showGenericIcon ? Icon(AIcons.album, size: size) : null);
}

View file

@ -1,3 +1,4 @@
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
@ -7,7 +8,6 @@ import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
@ -56,7 +56,7 @@ class CoordinateFilter extends CollectionFilter {
String getLabel(BuildContext context) => _formatBounds(context.l10n, context.read<Settings>().coordinateFormat);
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.geoBounds, size: size);
@override
String get category => type;

View file

@ -3,7 +3,6 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class FavouriteFilter extends CollectionFilter {
static const type = 'favourite';
@ -30,7 +29,7 @@ class FavouriteFilter extends CollectionFilter {
String getLabel(BuildContext context) => context.l10n.filterFavouriteLabel;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size);
@override
Future<Color> color(BuildContext context) => SynchronousFuture(Colors.red);

View file

@ -81,7 +81,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
String getTooltip(BuildContext context) => getLabel(context);
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => null;
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => null;
Future<Color> color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context)));

View file

@ -58,15 +58,13 @@ class LocationFilter extends CollectionFilter {
String getLabel(BuildContext context) => _location.isEmpty ? context.l10n.filterLocationEmptyLabel : _location;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) {
if (_countryCode != null && device.canRenderFlagEmojis) {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size, shadows: const []),
style: TextStyle(fontSize: size),
textScaleFactor: 1.0,
);
}

View file

@ -68,7 +68,7 @@ class MimeFilter extends CollectionFilter {
}
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
@override
String get category => type;

View file

@ -64,7 +64,7 @@ class QueryFilter extends CollectionFilter {
String get universalLabel => query;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size);
@override
Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor);

View file

@ -44,7 +44,7 @@ class TagFilter extends CollectionFilter {
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
@override
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
@override
String get category => type;

View file

@ -94,7 +94,7 @@ class TypeFilter extends CollectionFilter {
}
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
@override
String get category => type;

View file

@ -1,7 +1,7 @@
import 'package:aves/l10n/l10n.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';

View file

@ -19,6 +19,7 @@ class SettingsDefaults {
static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly;
static const homePage = HomePageSetting.collection;
static const tileLayout = TileLayout.grid;
// drawer
static final drawerTypeBookmarks = [
@ -66,6 +67,7 @@ class SettingsDefaults {
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
static const viewerUseCutout = true;
static const viewerMaxBrightness = false;
static const enableMotionPhotoAutoPlay = false;
// video
static const videoQuickActions = [

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
@ -15,7 +16,6 @@ import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private();
@ -49,6 +49,7 @@ class Settings extends ChangeNotifier {
static const homePageKey = 'home_page';
static const catalogTimeZoneKey = 'catalog_time_zone';
static const tileExtentPrefixKey = 'tile_extent_';
static const tileLayoutPrefixKey = 'tile_layout_';
// drawer
static const drawerTypeBookmarksKey = 'drawer_type_bookmarks';
@ -82,6 +83,8 @@ class Settings extends ChangeNotifier {
static const enableOverlayBlurEffectKey = 'enable_overlay_blur_effect';
static const viewerUseCutoutKey = 'viewer_use_cutout';
static const viewerMaxBrightnessKey = 'viewer_max_brightness';
static const enableMotionPhotoAutoPlayKey = 'motion_photo_auto_play';
static const imageBackgroundKey = 'image_background';
// video
static const videoQuickActionsKey = 'video_quick_actions';
@ -103,9 +106,6 @@ class Settings extends ChangeNotifier {
static const coordinateFormatKey = 'coordinates_format';
static const unitSystemKey = 'unit_system';
// rendering
static const imageBackgroundKey = 'image_background';
// search
static const saveSearchHistoryKey = 'save_search_history';
static const searchHistoryKey = 'search_history';
@ -217,12 +217,26 @@ class Settings extends ChangeNotifier {
_appliedLocale = null;
}
List<Locale> _systemLocalesFallback = [];
set systemLocalesFallback(List<Locale> locales) => _systemLocalesFallback = locales;
Locale? _appliedLocale;
Locale get appliedLocale {
if (_appliedLocale == null) {
final preferredLocale = locale;
_appliedLocale = basicLocaleListResolution(preferredLocale != null ? [preferredLocale] : null, AppLocalizations.supportedLocales);
final _locale = locale;
final preferredLocales = <Locale>[];
if (_locale != null) {
preferredLocales.add(_locale);
} else {
preferredLocales.addAll(WidgetsBinding.instance!.window.locales);
if (preferredLocales.isEmpty) {
// the `window` locales may be empty in a window-less service context
preferredLocales.addAll(_systemLocalesFallback);
}
}
_appliedLocale = basicLocaleListResolution(preferredLocales, AppLocalizations.supportedLocales);
}
return _appliedLocale!;
}
@ -247,6 +261,10 @@ class Settings extends ChangeNotifier {
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values);
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
// drawer
List<CollectionFilter?> get drawerTypeBookmarks =>
@ -360,6 +378,14 @@ class Settings extends ChangeNotifier {
set viewerMaxBrightness(bool newValue) => setAndNotify(viewerMaxBrightnessKey, newValue);
bool get enableMotionPhotoAutoPlay => getBoolOrDefault(enableMotionPhotoAutoPlayKey, SettingsDefaults.enableMotionPhotoAutoPlay);
set enableMotionPhotoAutoPlay(bool newValue) => setAndNotify(enableMotionPhotoAutoPlayKey, newValue);
EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values);
set imageBackground(EntryBackground newValue) => setAndNotify(imageBackgroundKey, newValue.toString());
// video
List<VideoAction> get videoQuickActions => getEnumListOrDefault(videoQuickActionsKey, SettingsDefaults.videoQuickActions, VideoAction.values);
@ -422,12 +448,6 @@ class Settings extends ChangeNotifier {
set unitSystem(UnitSystem newValue) => setAndNotify(unitSystemKey, newValue.toString());
// rendering
EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values);
set imageBackground(EntryBackground newValue) => setAndNotify(imageBackgroundKey, newValue.toString());
// search
bool get saveSearchHistory => getBoolOrDefault(saveSearchHistoryKey, SettingsDefaults.saveSearchHistory);
@ -570,6 +590,12 @@ class Settings extends ChangeNotifier {
} else {
debugPrint('failed to import key=$key, value=$value is not a double');
}
} else if (key.startsWith(tileLayoutPrefixKey)) {
if (value is String) {
_prefs!.setString(key, value);
} else {
debugPrint('failed to import key=$key, value=$value is not a string');
}
} else {
switch (key) {
case subtitleTextColorKey:
@ -602,6 +628,7 @@ class Settings extends ChangeNotifier {
case enableOverlayBlurEffectKey:
case viewerUseCutoutKey:
case viewerMaxBrightnessKey:
case enableMotionPhotoAutoPlayKey:
case enableVideoHardwareAccelerationKey:
case enableVideoAutoPlayKey:
case subtitleShowOutlineKey:
@ -622,12 +649,12 @@ class Settings extends ChangeNotifier {
case albumSortFactorKey:
case countrySortFactorKey:
case tagSortFactorKey:
case imageBackgroundKey:
case videoLoopModeKey:
case subtitleTextAlignmentKey:
case infoMapStyleKey:
case coordinateFormatKey:
case unitSystemKey:
case imageBackgroundKey:
case accessibilityAnimationsKey:
case timeToTakeActionKey:
if (value is String) {

View file

@ -176,7 +176,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
processed.add,
onError: (error) => reportService.recordError('renameEntry failed with error=$error', null),
onDone: () async {
final successOps = processed.where((e) => e.success).toSet();
final successOps = processed.where((e) => e.success && !e.skipped).toSet();
if (successOps.isEmpty) {
completer.complete(false);
return;

View file

@ -7,3 +7,5 @@ enum AlbumChipGroupFactor { none, importance, volume }
enum EntrySortFactor { date, size, name }
enum EntryGroupFactor { none, album, month, day }
enum TileLayout { grid, list }

View file

@ -1,5 +1,5 @@
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension ExtraSourceState on SourceState {
String? getName(AppLocalizations l10n) {

View file

@ -14,6 +14,7 @@ class MimeTypes {
static const art = 'image/x-jg';
static const djvu = 'image/vnd.djvu';
static const jxl = 'image/jxl';
static const psdVnd = 'image/vnd.adobe.photoshop';
static const psdX = 'image/x-photoshop';
@ -47,6 +48,7 @@ class MimeTypes {
static const mp2t = 'video/mp2t'; // .m2ts, .ts
static const mp2ts = 'video/mp2ts'; // .ts (prefer `mp2t` when possible)
static const mp4 = 'video/mp4';
static const mpeg = 'video/mpeg';
static const ogv = 'video/ogg';
static const webm = 'video/webm';
@ -55,6 +57,7 @@ class MimeTypes {
// JB2, JPC, JPX?
static const octetStream = 'application/octet-stream';
static const zip = 'application/zip';
// groups
@ -64,11 +67,11 @@ class MimeTypes {
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
// TODO TLAD [codec] make it dynamic if it depends on OS/lib versions
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX, octetStream};
static const Set<String> undecodableImages = {art, crw, djvu, jxl, psdVnd, psdX, octetStream, zip};
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, ogv, webm};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, mpeg, ogv, webm};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};

View file

@ -9,10 +9,13 @@ class XMP {
'aux': 'Exif Aux',
'avm': 'Astronomy Visualization',
'Camera': 'Camera',
'cc': 'Creative Commons',
'crd': 'Camera Raw Defaults',
'creatorAtom': 'After Effects',
'crs': 'Camera Raw Settings',
'dc': 'Dublin Core',
'drone-dji': 'DJI Drone',
'dwc': 'Darwin Core',
'exif': 'Exif',
'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images',

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums.dart';
@ -10,7 +11,6 @@ import 'package:aves/services/common/services.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AnalysisService {
static const platform = MethodChannel('deckers.thibault/aves/analysis');
@ -112,6 +112,7 @@ class Analyzer {
stopSignal: ValueNotifier(false),
);
settings.systemLocalesFallback = await deviceService.getLocales();
_l10n = await AppLocalizations.delegate.load(settings.appliedLocale);
_serviceStateNotifier.value = AnalyzerState.running;
await _source.init();

View file

@ -44,6 +44,10 @@ class PlatformAndroidAppService implements AndroidAppService {
if (kakaoTalk != null) {
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
}
final imagingEdge = packages.firstWhereOrNull((package) => package.packageName == 'com.sony.playmemories.mobile');
if (imagingEdge != null) {
imagingEdge.ownedDirs.add('Imaging Edge Mobile');
}
return packages;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
@ -140,7 +144,7 @@ class PlatformAndroidAppService implements AndroidAppService {
@override
Future<bool> shareEntries(Iterable<AvesEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps
// loosen MIME type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try {

View file

@ -133,7 +133,7 @@ class AndroidDebugService {
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
try {
// returns map with the mime type and tag count for each directory found by `metadata-extractor`
// returns map with the MIME type and tag count for each directory found by `metadata-extractor`
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,

View file

@ -3,65 +3,87 @@ import 'package:flutter/foundation.dart';
@immutable
class ImageOpEvent extends Equatable {
final bool success;
final bool success, skipped;
final String uri;
@override
List<Object?> get props => [success, uri];
List<Object?> get props => [success, skipped, uri];
const ImageOpEvent({
required this.success,
required this.skipped,
required this.uri,
});
factory ImageOpEvent.fromMap(Map map) {
final skipped = map['skipped'] ?? false;
return ImageOpEvent(
success: map['success'] ?? false,
success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'],
);
}
}
@immutable
class MoveOpEvent extends ImageOpEvent {
final Map newFields;
const MoveOpEvent({required bool success, required String uri, required this.newFields})
: super(
@override
List<Object?> get props => [success, skipped, uri, newFields];
const MoveOpEvent({
required bool success,
required bool skipped,
required String uri,
required this.newFields,
}) : super(
success: success,
skipped: skipped,
uri: uri,
);
factory MoveOpEvent.fromMap(Map map) {
final newFields = map['newFields'] ?? {};
final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false);
return MoveOpEvent(
success: map['success'] ?? false,
success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'],
newFields: map['newFields'] ?? {},
newFields: newFields,
);
}
@override
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
}
@immutable
class ExportOpEvent extends MoveOpEvent {
final int? pageId;
@override
List<Object?> get props => [success, uri, pageId];
List<Object?> get props => [success, skipped, uri, pageId, newFields];
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields})
: super(
const ExportOpEvent({
required bool success,
required bool skipped,
required String uri,
this.pageId,
required Map newFields,
}) : super(
success: success,
skipped: skipped,
uri: uri,
newFields: newFields,
);
factory ExportOpEvent.fromMap(Map map) {
final newFields = map['newFields'] ?? {};
final skipped = (map['skipped'] ?? false) || (newFields['skipped'] ?? false);
return ExportOpEvent(
success: map['success'] ?? false,
success: (map['success'] ?? false) || skipped,
skipped: skipped,
uri: map['uri'],
pageId: map['pageId'],
newFields: map['newFields'] ?? {},
newFields: newFields,
);
}
}

View file

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
@ -6,6 +8,8 @@ abstract class DeviceService {
Future<String?> getDefaultTimeZone();
Future<List<Locale>> getLocales();
Future<int> getPerformanceClass();
}
@ -33,6 +37,26 @@ class PlatformDeviceService implements DeviceService {
return null;
}
@override
Future<List<Locale>> getLocales() async {
try {
final result = await platform.invokeMethod('getLocales');
if (result != null) {
return (result as List).cast<Map>().map((tags) {
final language = tags['language'] as String?;
final country = tags['country'] as String?;
return Locale(
language ?? 'und',
(country != null && country.isEmpty) ? null : country,
);
}).toList();
}
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@override
Future<int> getPerformanceClass() async {
try {

View file

@ -1,13 +1,11 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
// names should match possible values on platform
enum NameConflictStrategy { rename, replace, skip }
extension ExtraNameConflictStrategy on NameConflictStrategy {
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
String toPlatform() => describeEnum(this);
String toPlatform() => name;
String getName(BuildContext context) {
switch (this) {

View file

@ -15,6 +15,8 @@ import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
abstract class MediaFileService {
String get newOpId;
Future<AvesEntry?> getEntry(String uri, String? mimeType);
Future<Uint8List> getSvg(
@ -68,10 +70,16 @@ abstract class MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
Future<void> cancelFileOp(String opId);
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
});
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
@ -120,6 +128,9 @@ class PlatformMediaFileService implements MediaFileService {
};
}
@override
String get newOpId => DateTime.now().millisecondsSinceEpoch.toString();
@override
Future<AvesEntry?> getEntry(String uri, String? mimeType) async {
try {
@ -194,8 +205,10 @@ class PlatformMediaFileService implements MediaFileService {
// `await` here, so that `completeError` will be caught below
return await completer.future;
} on PlatformException catch (e, stack) {
if (!MimeTypes.knownMediaTypes.contains(mimeType)) {
await reportService.recordError(e, stack);
}
}
return Uint8List(0);
}
@ -296,10 +309,25 @@ class PlatformMediaFileService implements MediaFileService {
Future<T>? resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
Future<void> cancelFileOp(String opId) async {
try {
await platform.invokeMethod('cancelFileOp', <String, dynamic>{
'opId': opId,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@override
Stream<ImageOpEvent> delete({
String? opId,
required Iterable<AvesEntry> entries,
}) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e, stack) {
@ -309,8 +337,9 @@ class PlatformMediaFileService implements MediaFileService {
}
@override
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
Stream<MoveOpEvent> move({
String? opId,
required Iterable<AvesEntry> entries,
required bool copy,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
@ -318,6 +347,7 @@ class PlatformMediaFileService implements MediaFileService {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'move',
'id': opId,
'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy,
'destinationPath': destinationAlbum,

View file

@ -4,7 +4,6 @@ import 'package:aves/model/entry.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/utils/string_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:xml/xml.dart';

View file

@ -36,7 +36,7 @@ abstract class StorageService {
// return whether operation succeeded (`null` if user cancelled)
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
Future<Uint8List> openFile(String mimeType);
Future<Uint8List> openFile([String? mimeType]);
}
class PlatformStorageService implements StorageService {
@ -231,7 +231,7 @@ class PlatformStorageService implements StorageService {
}
@override
Future<Uint8List> openFile(String mimeType) async {
Future<Uint8List> openFile([String? mimeType]) async {
try {
final completer = Completer<Uint8List>.sync();
final sink = OutputBuffer();

View file

@ -59,6 +59,7 @@ class Durations {
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const highlightJumpDelay = Duration(milliseconds: 400);
static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const motionPhotoAutoPlayDelay = Duration(milliseconds: 700);
static const videoOverlayHideDelay = Duration(milliseconds: 500);
static const videoProgressTimerInterval = Duration(milliseconds: 300);
static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -32,10 +32,16 @@ class AIcons {
static const IconData tag = Icons.local_offer_outlined;
static const IconData tagOff = MdiIcons.tagOffOutline;
// view
static const IconData group = Icons.group_work_outlined;
static const IconData layout = Icons.grid_view_outlined;
static const IconData sort = Icons.sort_outlined;
// actions
static const IconData add = Icons.add_circle_outline;
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
static const IconData addTag = MdiIcons.tagPlusOutline;
static const IconData cancel = Icons.cancel_outlined;
static const IconData replay10 = Icons.replay_10_outlined;
static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined;
@ -53,7 +59,6 @@ class AIcons {
static const IconData filterOff = MdiIcons.filterOffOutline;
static const IconData geoBounds = Icons.public_outlined;
static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined;
static const IconData hide = Icons.visibility_off_outlined;
static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined;
@ -79,7 +84,6 @@ class AIcons {
static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined;
static const IconData show = Icons.visibility_outlined;
static const IconData sort = Icons.sort_outlined;
static const IconData speed = Icons.speed_outlined;
static const IconData stats = Icons.pie_chart_outlined;
static const IconData streams = Icons.translate_outlined;
@ -87,6 +91,7 @@ class AIcons {
static const IconData streamAudio = Icons.audiotrack_outlined;
static const IconData streamText = Icons.closed_caption_outlined;
static const IconData videoSettings = Icons.video_settings_outlined;
static const IconData view = Icons.grid_view_outlined;
static const IconData zoomIn = Icons.add_outlined;
static const IconData zoomOut = Icons.remove_outlined;
static const IconData collapse = Icons.expand_less_outlined;

View file

@ -1,7 +1,6 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class Themes {
static const _accentColor = Colors.indigoAccent;

View file

@ -2,11 +2,10 @@ import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:latlong2/latlong.dart';
class Constants {
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// as of Flutter v2.8.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// so we give it a `strutStyle` with a slightly larger height
static const overflowStrutStyle = StrutStyle(height: 1.3);

View file

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -9,6 +7,7 @@ class AboutCredits extends StatelessWidget {
const AboutCredits({Key? key}) : super(key: key);
static const translators = {
'Deutsch': 'JanWaldhorn',
'Русский': 'D3ZOXY',
};

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart';
@ -27,7 +27,6 @@ import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -122,9 +121,7 @@ class _AvesAppState extends State<AvesApp> {
darkTheme: Themes.darkTheme,
themeMode: ThemeMode.dark,
locale: settingsLocale,
localizationsDelegates: const [
...AppLocalizations.localizationsDelegates,
],
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
// checkerboardRasterCacheImages: true,
// checkerboardOffscreenLayers: true,
@ -200,7 +197,7 @@ class _AvesAppState extends State<AvesApp> {
? 'profile'
: 'debug',
'has_play_services': hasPlayServices,
'locales': window.locales.join(', '),
'locales': WidgetsBinding.instance!.window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
});
_navigatorObservers = [

View file

@ -12,6 +12,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/collection/query_bar.dart';
@ -19,9 +20,8 @@ import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
@ -283,9 +283,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
];
}
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
// TODO TLAD [dart 2.15] replace `describeEnum()` by `enum.name`
Key _getActionKey(EntrySetAction action) => Key('menu-${describeEnum(action)}');
// key is expected by test driver (e.g. 'menu-configureView', 'menu-map')
Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}');
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
final onPressed = enabled ? () => _onActionSelected(action) : null;
@ -397,11 +396,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Future<void> _onActionSelected(EntrySetAction action) async {
switch (action) {
// general
case EntrySetAction.sort:
await _sort();
break;
case EntrySetAction.group:
await _group();
case EntrySetAction.configureView:
await _configureView();
break;
case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select();
@ -436,47 +432,42 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
}
Future<void> _sort() async {
final value = await showDialog<EntrySortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntrySortFactor>(
initialValue: settings.collectionSortFactor,
options: {
EntrySortFactor.date: context.l10n.collectionSortDate,
EntrySortFactor.size: context.l10n.collectionSortSize,
EntrySortFactor.name: context.l10n.collectionSortName,
},
title: context.l10n.collectionSortTitle,
),
Future<void> _configureView() async {
final initialValue = Tuple3(
settings.collectionSortFactor,
settings.collectionSectionFactor,
settings.getTileLayout(CollectionPage.routeName),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.collectionSortFactor = value;
}
}
Future<void> _group() async {
final value = await showDialog<EntryGroupFactor>(
final value = await showDialog<Tuple3<EntrySortFactor?, EntryGroupFactor?, TileLayout?>>(
context: context,
builder: (context) {
final l10n = context.l10n;
return AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionSectionFactor,
options: {
return TileViewDialog<EntrySortFactor, EntryGroupFactor, TileLayout>(
initialValue: initialValue,
sortOptions: {
EntrySortFactor.date: l10n.collectionSortDate,
EntrySortFactor.size: l10n.collectionSortSize,
EntrySortFactor.name: l10n.collectionSortName,
},
groupOptions: {
EntryGroupFactor.album: l10n.collectionGroupAlbum,
EntryGroupFactor.month: l10n.collectionGroupMonth,
EntryGroupFactor.day: l10n.collectionGroupDay,
EntryGroupFactor.none: l10n.collectionGroupNone,
},
title: l10n.collectionGroupTitle,
layoutOptions: {
TileLayout.grid: l10n.tileLayoutGrid,
TileLayout.list: l10n.tileLayoutList,
},
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) {
settings.collectionSectionFactor = value;
if (value != null && initialValue != value) {
settings.collectionSortFactor = value.item1!;
settings.collectionSectionFactor = value.item2!;
settings.setTileLayout(CollectionPage.routeName, value.item3!);
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/ref/mime_types.dart';
@ -11,21 +12,22 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/app_bar.dart';
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
import 'package:aves/widgets/collection/grid/list_details_theme.dart';
import 'package:aves/widgets/collection/grid/section_layout.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/grid/tile.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/scaling.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:flutter/material.dart';
@ -74,11 +76,13 @@ class _CollectionGridContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final settingsRouteKey = context.read<TileExtentController>().settingsRouteKey;
final tileLayout = context.select<Settings, TileLayout>((s) => s.getTileLayout(settingsRouteKey));
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
builder: (context, thumbnailExtent, child) {
return Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
@ -89,23 +93,28 @@ class _CollectionGridContent extends StatelessWidget {
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
return GridTheme(
extent: tileExtent,
extent: thumbnailExtent,
child: EntryListDetailsTheme(
extent: thumbnailExtent,
child: SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: scrollableWidth,
tileLayout: tileLayout,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (entry) => InteractiveThumbnail(
tileExtent: thumbnailExtent,
tileBuilder: (entry) => InteractiveTile(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
thumbnailExtent: thumbnailExtent,
tileLayout: tileLayout,
isScrollingNotifier: _isScrollingNotifier,
),
tileAnimationDelay: tileAnimationDelay,
child: child!,
),
),
);
},
child: child,
@ -115,6 +124,7 @@ class _CollectionGridContent extends StatelessWidget {
collection: collection,
isScrollingNotifier: _isScrollingNotifier,
scrollController: PrimaryScrollController.of(context)!,
tileLayout: tileLayout,
),
);
return sectionedListLayoutProvider;
@ -127,27 +137,28 @@ class _CollectionSectionedContent extends StatefulWidget {
final CollectionLens collection;
final ValueNotifier<bool> isScrollingNotifier;
final ScrollController scrollController;
final TileLayout tileLayout;
const _CollectionSectionedContent({
required this.collection,
required this.isScrollingNotifier,
required this.scrollController,
required this.tileLayout,
});
@override
_CollectionSectionedContentState createState() => _CollectionSectionedContentState();
}
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> {
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> {
CollectionLens get collection => widget.collection;
@override
TileLayout get tileLayout => widget.tileLayout;
ScrollController get scrollController => widget.scrollController;
@override
final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
@override
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
@override
@ -169,6 +180,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
final scaler = _CollectionScaler(
scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
tileLayout: tileLayout,
child: scrollView,
);
@ -181,18 +193,26 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
child: scaler,
);
return selector;
return GridItemTracker<AvesEntry>(
scrollableKey: scrollableKey,
tileLayout: tileLayout,
appBarHeightNotifier: appBarHeightNotifier,
scrollController: scrollController,
child: selector,
);
}
}
class _CollectionScaler extends StatelessWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final TileLayout tileLayout;
final Widget child;
const _CollectionScaler({
required this.scrollableKey,
required this.appBarHeightNotifier,
required this.tileLayout,
required this.child,
});
@ -201,10 +221,12 @@ class _CollectionScaler extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey,
tileLayout: tileLayout,
heightForWidth: (width) => width,
gridBuilder: (center, tileSize, child) => CustomPaint(
painter: GridPainter(
center: center,
tileLayout: tileLayout,
tileCenter: center,
tileSize: tileSize,
spacing: tileSpacing,
borderWidth: DecoratedThumbnail.borderWidth,
@ -213,11 +235,13 @@ class _CollectionScaler extends StatelessWidget {
),
child: child,
),
scaledBuilder: (entry, tileSize) => DecoratedThumbnail(
scaledBuilder: (entry, tileSize) => EntryListDetailsTheme(
extent: tileSize.height,
child: Tile(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,
highlightable: false,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
tileLayout: tileLayout,
),
),
child: child,
);

View file

@ -36,7 +36,6 @@ import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -51,10 +50,8 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
}) {
switch (action) {
// general
case EntrySetAction.sort:
case EntrySetAction.configureView:
return true;
case EntrySetAction.group:
return sortFactor == EntrySortFactor.date;
case EntrySetAction.select:
return appMode.canSelect && !isSelecting;
case EntrySetAction.selectAll:
@ -98,8 +95,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final hasSelection = selectedItemCount > 0;
switch (action) {
case EntrySetAction.sort:
case EntrySetAction.group:
case EntrySetAction.configureView:
return true;
case EntrySetAction.select:
return hasItems;
@ -133,8 +129,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
void onActionSelected(BuildContext context, EntrySetAction action) {
switch (action) {
// general
case EntrySetAction.sort:
case EntrySetAction.group:
case EntrySetAction.configureView:
case EntrySetAction.select:
case EntrySetAction.selectAll:
case EntrySetAction.selectNone:
@ -227,7 +222,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)),
actions: [
TextButton(
@ -247,19 +241,23 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>(
context: context,
opStream: mediaFileService.delete(selectedItems),
opStream: mediaFileService.delete(opId: opId, entries: selectedItems),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async {
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
final successOps = processed.where((e) => e.success).toSet();
final deletedOps = successOps.where((e) => !e.skipped).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet();
await source.removeEntries(deletedUris);
selection.browse();
source.resumeMonitoring();
final deletedCount = deletedUris.length;
if (deletedCount < todoCount) {
final count = todoCount - deletedCount;
final successCount = successOps.length;
if (successCount < todoCount) {
final count = todoCount - successCount;
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
}
@ -271,19 +269,12 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
final l10n = context.l10n;
final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final destinationAlbum = await Navigator.push(
context,
MaterialPageRoute<String>(
settings: const RouteSettings(name: AlbumPickPage.routeName),
builder: (context) => AlbumPickPage(source: source, moveType: moveType),
),
);
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
final destinationAlbum = await pickAlbum(context: context, moveType: moveType);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
@ -323,19 +314,23 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
nameConflictStrategy = value;
}
final source = context.read<CollectionSource>();
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
todoItems,
opId: opId,
entries: todoItems,
copy: copy,
destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();
await source.updateAfterMove(
todoEntries: todoItems,
copy: copy,
@ -414,18 +409,25 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final source = context.read<CollectionSource>();
source.pauseMonitoring();
var cancelled = false;
showOpReport<ImageOpEvent>(
context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
if (cancelled) {
return ImageOpEvent(success: true, skipped: true, uri: entry.uri);
} else {
final dataTypes = await op(entry);
return ImageOpEvent(success: dataTypes.isNotEmpty, uri: entry.uri);
return ImageOpEvent(success: dataTypes.isNotEmpty, skipped: false, uri: entry.uri);
}
}).asBroadcastStream(),
itemCount: todoCount,
onCancel: () => cancelled = true,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final editedOps = successOps.where((e) => !e.skipped).toSet();
selection.browse();
source.resumeMonitoring();
unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet()));
unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()));
final l10n = context.l10n;
final successCount = successOps.length;
@ -433,7 +435,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final count = todoCount - successCount;
showFeedback(context, l10n.collectionEditFailureFeedback(count));
} else {
final count = successCount;
final count = editedOps.length;
showFeedback(context, l10n.collectionEditSuccessFeedback(count));
}
},
@ -457,7 +459,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
builder: (context) {
final l10n = context.l10n;
return AvesDialog(
context: context,
title: l10n.unsupportedTypeDialogTitle,
content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))),
actions: [

View file

@ -0,0 +1,95 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/grid/list_details_theme.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EntryListDetails extends StatelessWidget {
final AvesEntry entry;
const EntryListDetails({
Key? key,
required this.entry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final detailsTheme = context.watch<EntryListDetailsThemeData>();
return Container(
padding: EntryListDetailsTheme.contentPadding,
foregroundDecoration: BoxDecoration(
border: Border(top: AvesBorder.side),
),
margin: EntryListDetailsTheme.contentMargin,
child: IconTheme.merge(
data: detailsTheme.iconTheme,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.bestTitle ?? context.l10n.viewerInfoUnknown,
style: detailsTheme.titleStyle,
softWrap: false,
overflow: detailsTheme.titleMaxLines == 1 ? TextOverflow.fade : TextOverflow.ellipsis,
maxLines: detailsTheme.titleMaxLines,
),
const SizedBox(height: EntryListDetailsTheme.titleDetailPadding),
if (detailsTheme.showDate) _buildDateRow(context, detailsTheme.captionStyle),
if (detailsTheme.showLocation && entry.hasGps) _buildLocationRow(context, detailsTheme.captionStyle),
],
),
),
);
}
Widget _buildDateRow(BuildContext context, TextStyle style) {
final locale = context.l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
final date = entry.bestDate;
final dateText = date != null ? formatDateTime(date, locale, use24hour) : Constants.overlayUnknown;
return Row(
children: [
const Icon(AIcons.date),
const SizedBox(width: 8),
Expanded(
child: Text(
dateText,
style: style,
strutStyle: Constants.overflowStrutStyle,
softWrap: false,
overflow: TextOverflow.fade,
),
),
],
);
}
Widget _buildLocationRow(BuildContext context, TextStyle style) {
final location = entry.hasAddress ? entry.shortAddress : settings.coordinateFormat.format(context.l10n, entry.latLng!);
return Row(
children: [
const Icon(AIcons.location),
const SizedBox(width: 8),
Expanded(
child: Text(
location,
style: style,
strutStyle: Constants.overflowStrutStyle,
softWrap: false,
overflow: TextOverflow.fade,
),
),
],
);
}
}

View file

@ -0,0 +1,99 @@
import 'package:aves/theme/format.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class EntryListDetailsTheme extends StatelessWidget {
final double extent;
final Widget child;
static const EdgeInsets contentMargin = EdgeInsets.symmetric(horizontal: 8);
static const EdgeInsets contentPadding = EdgeInsets.symmetric(vertical: 4);
static const double titleDetailPadding = 6;
const EntryListDetailsTheme({
Key? key,
required this.extent,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ProxyProvider<MediaQueryData, EntryListDetailsThemeData>(
update: (context, mq, previous) {
final locale = context.l10n.localeName;
final use24hour = mq.alwaysUse24HourFormat;
final textScaleFactor = mq.textScaleFactor;
final textTheme = Theme.of(context).textTheme;
final titleStyle = textTheme.bodyText2!;
final captionStyle = textTheme.caption!;
final titleLineHeight = (RenderParagraph(
TextSpan(text: 'Fake Title', style: titleStyle),
textDirection: TextDirection.ltr,
textScaleFactor: textScaleFactor,
)..layout(const BoxConstraints(), parentUsesSize: true))
.getMaxIntrinsicHeight(double.infinity);
final captionLineHeight = (RenderParagraph(
TextSpan(text: formatDateTime(DateTime.now(), locale, use24hour), style: captionStyle),
textDirection: TextDirection.ltr,
textScaleFactor: textScaleFactor,
strutStyle: Constants.overflowStrutStyle,
)..layout(const BoxConstraints(), parentUsesSize: true))
.getMaxIntrinsicHeight(double.infinity);
var titleMaxLines = 1;
var showDate = false;
var showLocation = false;
var availableHeight = extent - contentMargin.vertical - contentPadding.vertical;
if (availableHeight >= titleLineHeight + titleDetailPadding + captionLineHeight) {
showDate = true;
availableHeight -= titleLineHeight + titleDetailPadding + captionLineHeight;
if (availableHeight >= captionLineHeight) {
showLocation = true;
availableHeight -= captionLineHeight;
titleMaxLines += availableHeight ~/ titleLineHeight;
}
}
return EntryListDetailsThemeData(
extent: extent,
titleMaxLines: titleMaxLines,
showDate: showDate,
showLocation: showLocation,
titleStyle: titleStyle,
captionStyle: captionStyle,
iconTheme: IconThemeData(
color: captionStyle.color,
size: captionStyle.fontSize! * textScaleFactor,
),
);
},
child: child,
);
}
}
class EntryListDetailsThemeData {
final double extent;
final int titleMaxLines;
final bool showDate, showLocation;
final TextStyle titleStyle, captionStyle;
final IconThemeData iconTheme;
const EntryListDetailsThemeData({
required this.extent,
required this.titleMaxLines,
required this.showDate,
required this.showLocation,
required this.titleStyle,
required this.captionStyle,
required this.iconTheme,
});
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/widgets/collection/grid/headers/any.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
@ -12,6 +13,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
Key? key,
required this.collection,
required double scrollableWidth,
required TileLayout tileLayout,
required int columnCount,
required double spacing,
required double tileExtent,
@ -21,6 +23,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesE
}) : super(
key: key,
scrollableWidth: scrollableWidth,
tileLayout: tileLayout,
columnCount: columnCount,
spacing: spacing,
tileWidth: tileExtent,

View file

@ -2,32 +2,36 @@ import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/collection/grid/list_details.dart';
import 'package:aves/widgets/collection/grid/list_details_theme.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/grid/scaling.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class InteractiveThumbnail extends StatelessWidget {
class InteractiveTile extends StatelessWidget {
final CollectionLens collection;
final AvesEntry entry;
final double tileExtent;
final double thumbnailExtent;
final TileLayout tileLayout;
final ValueNotifier<bool>? isScrollingNotifier;
const InteractiveThumbnail({
const InteractiveTile({
Key? key,
required this.collection,
required this.entry,
required this.tileExtent,
required this.thumbnailExtent,
required this.tileLayout,
this.isScrollingNotifier,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey(entry.uri),
return InkWell(
onTap: () {
final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) {
@ -51,13 +55,13 @@ class InteractiveThumbnail extends StatelessWidget {
},
child: MetaData(
metaData: ScalerMetadata(entry),
child: DecoratedThumbnail(
child: Tile(
entry: entry,
tileExtent: tileExtent,
// when the user is scrolling faster than we can retrieve the thumbnails,
// the retrieval task queue can pile up for thumbnails that got disposed
// in this case we pause the image retrieval task to get it out of the queue
cancellableNotifier: isScrollingNotifier,
thumbnailExtent: thumbnailExtent,
tileLayout: tileLayout,
selectable: true,
highlightable: true,
isScrollingNotifier: isScrollingNotifier,
// hero tag should include a collection identifier, so that it animates
// between different views of the entry in the same collection (e.g. thumbnails <-> viewer)
// but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer)
@ -86,3 +90,58 @@ class InteractiveThumbnail extends StatelessWidget {
);
}
}
class Tile extends StatelessWidget {
final AvesEntry entry;
final double thumbnailExtent;
final TileLayout tileLayout;
final bool selectable, highlightable;
final ValueNotifier<bool>? isScrollingNotifier;
final Object? Function()? heroTagger;
const Tile({
Key? key,
required this.entry,
required this.thumbnailExtent,
required this.tileLayout,
this.selectable = false,
this.highlightable = false,
this.isScrollingNotifier,
this.heroTagger,
}) : super(key: key);
@override
Widget build(BuildContext context) {
switch (tileLayout) {
case TileLayout.grid:
return _buildThumbnail();
case TileLayout.list:
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox.square(
dimension: context.select<EntryListDetailsThemeData, double>((v) => v.extent),
child: _buildThumbnail(),
),
Expanded(
child: EntryListDetails(
entry: entry,
),
),
],
);
}
}
Widget _buildThumbnail() => DecoratedThumbnail(
entry: entry,
tileExtent: thumbnailExtent,
// when the user is scrolling faster than we can retrieve the thumbnails,
// the retrieval task queue can pile up for thumbnails that got disposed
// in this case we pause the image retrieval task to get it out of the queue
cancellableNotifier: isScrollingNotifier,
selectable: selectable,
highlightable: highlightable,
heroTagger: heroTagger,
);
}

View file

@ -55,7 +55,6 @@ mixin EntryEditorMixin {
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage),
actions: [
TextButton(

View file

@ -6,6 +6,9 @@ import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
@ -15,7 +18,17 @@ mixin FeedbackMixin {
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
void showFeedback(BuildContext context, String message, [SnackBarAction? action]) {
showFeedbackWithMessenger(context, ScaffoldMessenger.of(context), message, action);
ScaffoldMessengerState? scaffoldMessenger;
try {
scaffoldMessenger = ScaffoldMessenger.of(context);
} catch (e) {
// minor issue: the page triggering this feedback likely
// allows the user to navigate away and they did so
debugPrint('failed to find ScaffoldMessenger in context');
}
if (scaffoldMessenger != null) {
showFeedbackWithMessenger(context, scaffoldMessenger, message, action);
}
}
// provide the messenger if feedback happens as the widget is disposed
@ -60,32 +73,36 @@ mixin FeedbackMixin {
required BuildContext context,
required Stream<T> opStream,
required int itemCount,
VoidCallback? onCancel,
void Function(Set<T> processed)? onDone,
}) {
late OverlayEntry _opReportOverlayEntry;
_opReportOverlayEntry = OverlayEntry(
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ReportOverlay<T>(
opStream: opStream,
itemCount: itemCount,
onCancel: onCancel,
onDone: (processed) {
_opReportOverlayEntry.remove();
Navigator.of(context).pop();
onDone?.call(processed);
},
),
);
Overlay.of(context)!.insert(_opReportOverlayEntry);
}
}
class ReportOverlay<T> extends StatefulWidget {
final Stream<T> opStream;
final int itemCount;
final VoidCallback? onCancel;
final void Function(Set<T> processed) onDone;
const ReportOverlay({
Key? key,
required this.opStream,
required this.itemCount,
required this.onCancel,
required this.onDone,
}) : super(key: key);
@ -100,8 +117,9 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
Stream<T> get opStream => widget.opStream;
static const fontSize = 18.0;
static const radius = 160.0;
static const strokeWidth = 16.0;
static const strokeWidth = 8.0;
@override
void initState() {
@ -136,29 +154,28 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
@override
Widget build(BuildContext context) {
final progressColor = Theme.of(context).colorScheme.secondary;
return AbsorbPointer(
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
return WillPopScope(
onWillPop: () => SynchronousFuture(false),
child: StreamBuilder<T>(
stream: opStream,
builder: (context, snapshot) {
final processedCount = processed.length.toDouble();
final total = widget.itemCount;
assert(processedCount <= total);
final percent = min(1.0, processedCount / total);
final animate = context.select<Settings, bool>((v) => v.accessibilityAnimations.animate);
return FadeTransition(
opacity: _animation,
child: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.black,
Colors.black54,
],
),
),
child: Center(
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: radius + 2,
height: radius + 2,
decoration: const BoxDecoration(
color: Color(0xBB000000),
shape: BoxShape.circle,
),
),
if (animate)
Container(
width: radius,
@ -176,13 +193,32 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
backgroundColor: Colors.white24,
progressColor: progressColor,
animation: animate,
center: Text(NumberFormat.percentPattern().format(percent)),
center: Text(
NumberFormat.percentPattern().format(percent),
style: const TextStyle(fontSize: fontSize),
),
animateFromLastPercent: true,
),
if (widget.onCancel != null)
Material(
color: Colors.transparent,
child: Container(
width: radius,
height: radius,
margin: const EdgeInsets.only(top: fontSize),
alignment: const FractionalOffset(0.5, 0.75),
child: Tooltip(
message: context.l10n.cancelTooltip,
preferBelow: false,
child: IconButton(
icon: const Icon(AIcons.cancel),
onPressed: widget.onCancel,
),
),
),
),
],
),
),
),
);
},
),

View file

@ -50,7 +50,6 @@ mixin PermissionAwareMixin {
final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir);
final volume = dir.getVolumeDescription(context);
return AvesDialog(
context: context,
title: context.l10n.storageAccessDialogTitle,
content: Text(context.l10n.storageAccessDialogMessage(directory, volume)),
actions: [
@ -84,7 +83,6 @@ mixin PermissionAwareMixin {
final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir);
final volume = dir.getVolumeDescription(context);
return AvesDialog(
context: context,
title: context.l10n.restrictedAccessDialogTitle,
content: Text(context.l10n.restrictedAccessDialogMessage(directory, volume)),
actions: [

View file

@ -11,7 +11,6 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
mixin SizeAwareMixin {
Future<bool> checkFreeSpaceForMove(
@ -81,7 +80,6 @@ mixin SizeAwareMixin {
final freeSize = formatFileSize(locale, free);
final volume = destinationVolume.getDescription(context);
return AvesDialog(
context: context,
title: l10n.notEnoughSpaceDialogTitle,
content: Text(l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)),
actions: [

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:highlight/highlight.dart' show highlight, Node;
// adapted from package `flutter_highlight` v0.7.0 `HighlightView`

View file

@ -71,7 +71,6 @@ class _ColorPickerDialogState extends State<ColorPickerDialog> {
@override
Widget build(BuildContext context) {
return AvesDialog(
context: context,
scrollableContent: [
ColorPicker(
color: color,

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/*

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:url_launcher/url_launcher.dart';

View file

@ -2,9 +2,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class QueryBar extends StatefulWidget {
final ValueNotifier<String> queryNotifier;

View file

@ -132,18 +132,20 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
Matrix4? _lastTransform;
late Offset _initialFocalPoint;
late Offset _currentFocalPoint;
Offset? _currentFocalPoint;
late double _initialSpan;
late double _currentSpan;
late double _initialHorizontalSpan;
late double _currentHorizontalSpan;
late double _initialVerticalSpan;
late double _currentVerticalSpan;
late Offset _localFocalPoint;
_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>{};
late Offset _delta;
double get _scaleFactor => _initialSpan > 0.0 ? _currentSpan / _initialSpan : 1.0;
@ -222,11 +224,28 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
void _update() {
final int count = _pointerLocations.keys.length;
final Offset? previousFocalPoint = _currentFocalPoint;
// 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;
if (previousFocalPoint == null) {
_localFocalPoint = PointerEvent.transformPosition(
_lastTransform,
_currentFocalPoint!,
);
_delta = Offset.zero;
} else {
final Offset localPreviousFocalPoint = _localFocalPoint;
_localFocalPoint = PointerEvent.transformPosition(
_lastTransform,
_currentFocalPoint!,
);
_delta = _localFocalPoint - localPreviousFocalPoint;
}
// 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.
@ -234,9 +253,9 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
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();
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;
@ -273,7 +292,7 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
}
bool _reconfigure(int pointer) {
_initialFocalPoint = _currentFocalPoint;
_initialFocalPoint = _currentFocalPoint!;
_initialSpan = _currentSpan;
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;
@ -288,7 +307,7 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
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)));
invokeCallback<void>('onEnd', () => onEnd!(ScaleEndDetails(pointerCount: _pointerQueue.length)));
}
}
_state = _ScaleState.accepted;
@ -308,8 +327,8 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
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);
final double focalPointDelta = (_currentFocalPoint! - _initialFocalPoint).distance;
if (spanDelta > computeScaleSlop(pointerDeviceKind) || focalPointDelta > computePanSlop(pointerDeviceKind, gestureSettings)) resolve(GestureDisposition.accepted);
} else if (_state.index >= _ScaleState.accepted.index) {
resolve(GestureDisposition.accepted);
}
@ -325,11 +344,11 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
scale: _scaleFactor,
horizontalScale: _horizontalScaleFactor,
verticalScale: _verticalScaleFactor,
focalPoint: _currentFocalPoint,
localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint),
focalPoint: _currentFocalPoint!,
localFocalPoint: _localFocalPoint,
rotation: _computeRotationFactor(),
pointerCount: _pointerQueue.length,
delta: _currentFocalPoint - _initialFocalPoint,
focalPointDelta: _delta,
));
});
}
@ -339,8 +358,8 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
if (onStart != null)
invokeCallback<void>('onStart', () {
onStart!(ScaleStartDetails(
focalPoint: _currentFocalPoint,
localFocalPoint: PointerEvent.transformPosition(_lastTransform, _currentFocalPoint),
focalPoint: _currentFocalPoint!,
localFocalPoint: _localFocalPoint,
pointerCount: _pointerQueue.length,
));
});
@ -352,7 +371,7 @@ class EagerScaleGestureRecognizer extends OneSequenceGestureRecognizer {
_state = _ScaleState.started;
_dispatchOnStartCallbackIfNeeded();
if (dragStartBehavior == DragStartBehavior.start) {
_initialFocalPoint = _currentFocalPoint;
_initialFocalPoint = _currentFocalPoint!;
_initialSpan = _currentSpan;
_initialLine = _currentLine;
_initialHorizontalSpan = _currentHorizontalSpan;

View file

@ -1,5 +1,5 @@
import 'package:aves/l10n/l10n.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension ExtraContext on BuildContext {
String? get currentRouteName => ModalRoute.of(this)?.settings.name;

View file

@ -2,22 +2,45 @@ import 'dart:async';
import 'dart:math';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U>, WidgetsBindingObserver {
ValueNotifier<double> get appBarHeightNotifier;
class GridItemTracker<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final TileLayout tileLayout;
final ScrollController scrollController;
final Widget child;
ScrollController get scrollController;
const GridItemTracker({
Key? key,
required this.scrollableKey,
required this.appBarHeightNotifier,
required this.tileLayout,
required this.scrollController,
required this.child,
}) : super(key: key);
GlobalKey get scrollableKey;
@override
_GridItemTrackerState createState() => _GridItemTrackerState<T>();
}
class _GridItemTrackerState<T> extends State<GridItemTracker<T>> with WidgetsBindingObserver {
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
ScrollController get scrollController => widget.scrollController;
@override
Widget build(BuildContext context) {
return widget.child;
}
Size get scrollableSize {
final scrollableContext = scrollableKey.currentContext!;
final scrollableContext = widget.scrollableKey.currentContext!;
return (scrollableContext.findRenderObject() as RenderBox).size;
}
@ -44,11 +67,27 @@ mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U>, WidgetsBind
}
@override
void didUpdateWidget(covariant oldWidget) {
void didUpdateWidget(covariant GridItemTracker<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.tileLayout != widget.tileLayout) {
_onLayoutChange();
}
_saveLayoutMetrics();
}
@override
void didChangeMetrics() {
// the order of `WidgetsBindingObserver` metrics change notification is unreliable
// w.r.t. the `MediaQuery` update, and consequentially to this widget update:
// `WidgetsBindingObserver` is notified mostly before, sometimes after, the widget update
final orientation = _windowOrientation;
if (_lastOrientation != orientation) {
_lastOrientation = orientation;
_onLayoutChange();
_saveLayoutMetrics();
}
}
@override
void dispose() {
WidgetsBinding.instance!.removeObserver(this);
@ -96,18 +135,9 @@ mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U>, WidgetsBind
}
}
@override
void didChangeMetrics() {
final orientation = _windowOrientation;
if (_lastOrientation != orientation) {
_lastOrientation = orientation;
_onWindowOrientationChange();
}
}
Future<void> _saveLayoutMetrics() async {
// use a delay to obtain current layout metrics
// so that we can handle window orientation change beforehand with the previous metrics,
// so that we can handle window orientation change with the previous metrics,
// regardless of the `MediaQuery`/`WidgetsBindingObserver` order uncertainty
await Future.delayed(const Duration(milliseconds: 500));
@ -117,10 +147,10 @@ mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U>, WidgetsBind
}
}
// the order of `WidgetsBindingObserver` metrics change notification is unreliable
// w.r.t. the `MediaQuery` update, and consequentially to this widget update
// `WidgetsBindingObserver` is notified mostly before, sometimes after, the widget update
void _onWindowOrientationChange() {
void _onLayoutChange() {
// do not track when view shows top edge
if (scrollController.offset == 0) return;
final layout = _lastSectionedListLayout;
final halfSize = _lastScrollableSize / 2;
final center = Offset(

View file

@ -1,6 +1,7 @@
import 'dart:ui' as ui;
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/behaviour/eager_scale_gesture_recognizer.dart';
import 'package:aves/widgets/common/grid/theme.dart';
@ -21,6 +22,7 @@ class ScalerMetadata<T> {
class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final TileLayout tileLayout;
final double Function(double width) heightForWidth;
final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder;
final Widget Function(T item, Size tileSize) scaledBuilder;
@ -30,6 +32,7 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
const GridScaleGestureDetector({
Key? key,
required this.scrollableKey,
required this.tileLayout,
required this.heightForWidth,
required this.gridBuilder,
required this.scaledBuilder,
@ -111,17 +114,29 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
_extentMax = tileExtentController.effectiveExtentMax;
final halfSize = _startSize! / 2;
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height));
final tileCenter = renderMetaData.localToGlobal(Offset(halfSize.width, halfSize.height));
_overlayEntry = OverlayEntry(
builder: (context) => ScaleOverlay(
builder: (scaledTileSize) => SizedBox.fromSize(
builder: (context) => _ScaleOverlay(
builder: (scaledTileSize) {
late final double themeExtent;
switch (widget.tileLayout) {
case TileLayout.grid:
themeExtent = scaledTileSize.width;
break;
case TileLayout.list:
themeExtent = scaledTileSize.height;
break;
}
return SizedBox.fromSize(
size: scaledTileSize,
child: GridTheme(
extent: scaledTileSize.width,
extent: themeExtent,
child: widget.scaledBuilder(_metadata!.item, scaledTileSize),
),
),
center: thumbnailCenter,
);
},
tileLayout: widget.tileLayout,
center: tileCenter,
viewportWidth: gridWidth,
gridBuilder: widget.gridBuilder,
scaledSizeNotifier: _scaledSizeNotifier!,
@ -133,8 +148,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
void _onScaleUpdate(ScaleUpdateDetails details) {
if (_scaledSizeNotifier == null) return;
final s = details.scale;
switch (widget.tileLayout) {
case TileLayout.grid:
final scaledWidth = (_startSize!.width * s).clamp(_extentMin!, _extentMax!);
_scaledSizeNotifier!.value = Size(scaledWidth, widget.heightForWidth(scaledWidth));
break;
case TileLayout.list:
final scaledHeight = (_startSize!.height * s).clamp(_extentMin!, _extentMax!);
_scaledSizeNotifier!.value = Size(_startSize!.width, scaledHeight);
break;
}
}
void _onScaleEnd(ScaleEndDetails details) {
@ -148,7 +171,16 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
final tileExtentController = context.read<TileExtentController>();
final oldExtent = tileExtentController.extentNotifier.value;
// sanitize and update grid layout if necessary
final newExtent = tileExtentController.setUserPreferredExtent(_scaledSizeNotifier!.value.width);
late final double preferredExtent;
switch (widget.tileLayout) {
case TileLayout.grid:
preferredExtent = _scaledSizeNotifier!.value.width;
break;
case TileLayout.list:
preferredExtent = _scaledSizeNotifier!.value.height;
break;
}
final newExtent = tileExtentController.setUserPreferredExtent(preferredExtent);
_scaledSizeNotifier = null;
if (newExtent == oldExtent) {
_applyingScale = false;
@ -183,16 +215,18 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
}
}
class ScaleOverlay extends StatefulWidget {
class _ScaleOverlay extends StatefulWidget {
final Widget Function(Size scaledTileSize) builder;
final TileLayout tileLayout;
final Offset center;
final double viewportWidth;
final ValueNotifier<Size> scaledSizeNotifier;
final Widget Function(Offset center, Size extent, Widget child) gridBuilder;
final Widget Function(Offset center, Size tileSize, Widget child) gridBuilder;
const ScaleOverlay({
const _ScaleOverlay({
Key? key,
required this.builder,
required this.tileLayout,
required this.center,
required this.viewportWidth,
required this.scaledSizeNotifier,
@ -203,7 +237,7 @@ class ScaleOverlay extends StatefulWidget {
_ScaleOverlayState createState() => _ScaleOverlayState();
}
class _ScaleOverlayState extends State<ScaleOverlay> {
class _ScaleOverlayState extends State<_ScaleOverlay> {
bool _init = false;
Offset get center => widget.center;
@ -222,26 +256,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
child: Builder(
builder: (context) => IgnorePointer(
child: AnimatedContainer(
decoration: _init
? BoxDecoration(
gradient: RadialGradient(
center: FractionalOffset.fromOffsetAndSize(center, context.select<MediaQueryData, Size>((mq) => mq.size)),
radius: 1,
colors: const [
Colors.black,
Colors.black54,
],
),
)
: const BoxDecoration(
// provide dummy gradient to lerp to the other one during animation
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
],
),
),
decoration: _buildBackgroundDecoration(context),
duration: Durations.collectionScalingBackgroundAnimation,
child: ValueListenableBuilder<Size>(
valueListenable: widget.scaledSizeNotifier,
@ -281,17 +296,53 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
),
);
}
BoxDecoration _buildBackgroundDecoration(BuildContext context) {
late final Offset gradientCenter;
switch (widget.tileLayout) {
case TileLayout.grid:
gradientCenter = center;
break;
case TileLayout.list:
gradientCenter = Offset(0, center.dy);
break;
}
return _init
? BoxDecoration(
gradient: RadialGradient(
center: FractionalOffset.fromOffsetAndSize(gradientCenter, context.select<MediaQueryData, Size>((mq) => mq.size)),
radius: 1,
colors: const [
Colors.black,
Colors.black54,
// Colors.amber,
],
),
)
: const BoxDecoration(
// provide dummy gradient to lerp to the other one during animation
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
],
),
);
}
}
class GridPainter extends CustomPainter {
final Offset center;
final TileLayout tileLayout;
final Offset tileCenter;
final Size tileSize;
final double spacing, borderWidth;
final Radius borderRadius;
final Color color;
const GridPainter({
required this.center,
required this.tileLayout,
required this.tileCenter,
required this.tileSize,
required this.spacing,
required this.borderWidth,
@ -301,15 +352,18 @@ class GridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final tileWidth = tileSize.width;
final tileHeight = tileSize.height;
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = borderWidth
..shader = ui.Gradient.radial(
center,
tileWidth * 2,
late final Offset chipCenter;
late final Size chipSize;
late final int deltaColumn;
late final Shader strokeShader;
switch (tileLayout) {
case TileLayout.grid:
chipCenter = tileCenter;
chipSize = tileSize;
deltaColumn = 2;
strokeShader = ui.Gradient.radial(
tileCenter,
chipSize.shortestSide * 2,
[
color,
Colors.transparent,
@ -319,22 +373,52 @@ class GridPainter extends CustomPainter {
1,
],
);
break;
case TileLayout.list:
chipSize = Size.square(tileSize.shortestSide);
chipCenter = Offset(chipSize.width / 2, tileCenter.dy);
deltaColumn = 0;
strokeShader = ui.Gradient.linear(
tileCenter - Offset(0, chipSize.shortestSide * 3),
tileCenter + Offset(0, chipSize.shortestSide * 3),
[
Colors.transparent,
color,
color,
Colors.transparent,
],
[
0,
.2,
.8,
1,
],
);
break;
}
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = borderWidth
..shader = strokeShader;
final fillPaint = Paint()
..style = PaintingStyle.fill
..color = color.withOpacity(.25);
final deltaX = tileWidth + spacing;
final deltaY = tileHeight + spacing;
for (var i = -2; i <= 2; i++) {
final chipWidth = chipSize.width;
final chipHeight = chipSize.height;
final deltaX = tileSize.width + spacing;
final deltaY = tileSize.height + spacing;
for (var i = -deltaColumn; i <= deltaColumn; i++) {
final dx = deltaX * i;
for (var j = -2; j <= 2; j++) {
if (i == 0 && j == 0) continue;
final dy = deltaY * j;
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: center + Offset(dx, dy),
width: tileWidth,
height: tileHeight,
center: chipCenter + Offset(dx, dy),
width: chipWidth - borderWidth,
height: chipHeight - borderWidth,
),
borderRadius,
);

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart';
import 'package:collection/collection.dart';
@ -12,6 +13,7 @@ import 'package:provider/provider.dart';
abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
final double scrollableWidth;
final TileLayout tileLayout;
final int columnCount;
final double spacing, tileWidth, tileHeight;
final Widget Function(T item) tileBuilder;
@ -21,14 +23,17 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
const SectionedListLayoutProvider({
Key? key,
required this.scrollableWidth,
required this.columnCount,
required this.tileLayout,
required int columnCount,
required this.spacing,
required this.tileWidth,
required double tileWidth,
required this.tileHeight,
required this.tileBuilder,
required this.tileAnimationDelay,
required this.child,
}) : assert(scrollableWidth != 0),
columnCount = tileLayout == TileLayout.list ? 1 : columnCount,
tileWidth = tileLayout == TileLayout.list ? scrollableWidth : tileWidth,
super(key: key);
@override

View file

@ -6,7 +6,6 @@ import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class GridSelectionGestureDetector<T> extends StatefulWidget {

View file

@ -97,13 +97,9 @@ class _RenderSliverKnownExtentBoxAdaptor extends RenderSliverMultiBoxAdaptor {
double? leadingScrollOffset,
double? trailingScrollOffset,
}) {
return childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
);
// default implementation is an estimation via `childManager.estimateMaxScrollOffset()`
// but we have the accurate offset via pre-computed section layouts
return _sectionLayouts.last.maxOffset;
}
double computeMaxScrollOffset(SliverConstraints constraints) {

View file

@ -37,7 +37,7 @@ class AvesFilterDecoration {
class AvesFilterChip extends StatefulWidget {
final CollectionFilter filter;
final bool removable, showGenericIcon, useFilterColor;
final bool removable, showText, showGenericIcon, useFilterColor;
final AvesFilterDecoration? decoration;
final String? banner;
final Widget? leadingOverride, details;
@ -60,6 +60,7 @@ class AvesFilterChip extends StatefulWidget {
Key? key,
required this.filter,
this.removable = false,
this.showText = true,
this.showGenericIcon = true,
this.useFilterColor = true,
this.decoration,
@ -160,14 +161,17 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
@override
Widget build(BuildContext context) {
final decoration = widget.decoration;
final chipBackground = Theme.of(context).scaffoldBackgroundColor;
Widget? content;
if (widget.showText) {
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final iconSize = AvesFilterChip.iconSize * textScaleFactor;
final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon);
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
final decoration = widget.decoration;
Widget content = Row(
content = Row(
mainAxisSize: decoration != null ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -221,6 +225,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
child: content,
);
}
}
final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner;
@ -244,7 +249,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// as of Flutter v2.8.0, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null

View file

@ -1,12 +1,8 @@
import 'dart:ui';
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -203,25 +199,9 @@ class IconUtils {
required BuildContext context,
required String albumPath,
double? size,
bool embossed = false,
}) {
size ??= IconTheme.of(context).size;
Widget buildIcon(IconData icon) => embossed
? MediaQuery(
// `DecoratedIcon` internally uses `Text`,
// which size depends on the ambient `textScaleFactor`
// but we already accommodate for it upstream
data: context.read<MediaQueryData>().copyWith(textScaleFactor: 1.0),
child: DecoratedIcon(
icon,
shadows: Constants.embossShadows,
size: size,
),
)
: Icon(
icon,
size: size,
);
Widget buildIcon(IconData icon) => Icon(icon, size: size);
switch (androidFileUtils.getAlbumType(albumPath)) {
case AlbumType.camera:
return buildIcon(AIcons.cameraAlbum);

View file

@ -1,4 +1,6 @@
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EmptyContent extends StatelessWidget {
final IconData? icon;
@ -17,7 +19,11 @@ class EmptyContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
const color = Colors.blueGrey;
return Align(
return Padding(
padding: EdgeInsets.only(
bottom: context.select<MediaQueryData, double>((mq) => mq.effectiveBottomPadding),
),
child: Align(
alignment: alignment,
child: Column(
mainAxisSize: MainAxisSize.min,
@ -40,6 +46,7 @@ class EmptyContent extends StatelessWidget {
),
],
),
),
);
}
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
import 'package:aves/widgets/common/magnifier/controller/state.dart';

View file

@ -1,8 +1,5 @@
import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable

View file

@ -22,7 +22,6 @@ import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

View file

@ -2,7 +2,6 @@ import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart';
class ScaleLayerOptions extends LayerOptions {

View file

@ -2,7 +2,6 @@ import 'package:aves/model/entry.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ImageMarker extends StatelessWidget {
final AvesEntry? entry;

View file

@ -4,7 +4,6 @@ import 'package:aves/model/entry.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ThumbnailScroller extends StatefulWidget {

View file

@ -68,7 +68,7 @@ class DebugSettingsSection extends StatelessWidget {
'searchHistory': toMultiline(settings.searchHistory),
'lastVersionCheckDate': '${settings.lastVersionCheckDate}',
'locale': '${settings.locale}',
'systemLocale': '${WidgetsBinding.instance!.window.locale}',
'systemLocales': '${WidgetsBinding.instance!.window.locales}',
},
),
),

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