Merge branch 'develop'
This commit is contained in:
commit
cb3f97e286
166 changed files with 4433 additions and 1549 deletions
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
|
@ -50,9 +50,15 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build appbundle --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json
|
||||
flutter build apk --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json
|
||||
flutter build apk --flavor byAbi --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json
|
||||
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
|
||||
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
|
||||
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
|
||||
cp build/app/outputs/apk/izzy/release/*.apk outputs
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
@ -64,14 +70,14 @@ jobs:
|
|||
- name: Create a release with the APK and App Bundle.
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/bundle/universalRelease/*.aab,build/app/outputs/apk/universal/release/*.apk,build/app/outputs/apk/byAbi/release/*.apk"
|
||||
artifacts: "outputs/*"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload app bundle
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: appbundle
|
||||
path: build/app/outputs/bundle/universalRelease/app-universal-release.aab
|
||||
path: outputs/app-play-release.aab
|
||||
|
||||
release:
|
||||
name: Create beta release on Play Store.
|
||||
|
@ -90,7 +96,7 @@ jobs:
|
|||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
|
||||
packageName: deckers.thibault.aves
|
||||
releaseFiles: app-universal-release.aab
|
||||
releaseFiles: app-play-release.aab
|
||||
track: beta
|
||||
status: completed
|
||||
whatsNewDirectory: whatsnew
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.5.5] - 2021-11-08
|
||||
|
||||
### Added
|
||||
|
||||
- Russian translation (thanks D3ZOXY)
|
||||
- Info: set date from title
|
||||
- Collection: bulk editing (rotation, date setting, metadata removal)
|
||||
- Collection: custom quick actions for item browsing
|
||||
- Collection: live title filter
|
||||
- About: link to privacy policy
|
||||
- Video: quick action to play video in other app
|
||||
- Video: resume playback
|
||||
|
||||
### Changed
|
||||
|
||||
- use build flavors to match distribution channels: `play` (same as original) and `izzy` (no Crashlytics)
|
||||
- use 12/24 hour format settings from device to display times
|
||||
- Privacy: consent request on first launch for installed app inventory access
|
||||
- use File API to rename and delete items, when possible (primary storage, Android <11)
|
||||
- Video: changed video thumbnail strategy
|
||||
|
||||
## [v1.5.4] - 2021-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: use a foreground service when scanning many items
|
||||
|
|
11
README.md
11
README.md
|
@ -50,13 +50,22 @@ At this stage this project does *not* accept PRs, except for translations.
|
|||
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled.
|
||||
|
||||
### Donations
|
||||
|
||||
Some users have expressed the wish to financially support the project. I haven't set up any sponsorship system, but you can send contributions [here](https://paypal.me/ThibaultDeckers). Thanks! ❤️
|
||||
|
||||
## Project Setup
|
||||
|
||||
Before running or building the app, update the dependencies for the desired flavor:
|
||||
```
|
||||
# (cd scripts/; ./apply_flavor_play.sh)
|
||||
```
|
||||
|
||||
To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
|
||||
|
||||
You can run the app with `flutter run --flavor universal`.
|
||||
To run the app:
|
||||
```
|
||||
# flutter run -t lib/main_play.dart --flavor play
|
||||
```
|
||||
|
||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||
|
|
|
@ -2,8 +2,6 @@ plugins {
|
|||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
}
|
||||
|
||||
def appId = "deckers.thibault.aves"
|
||||
|
@ -77,18 +75,25 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
// the "splitting" dimension and its flavors are only for building purposes:
|
||||
// NDK ABI filters are not compatible with split APK generation
|
||||
// but we want to generate both a universal APK without x86 libs, and split APKs
|
||||
flavorDimensions "splitting"
|
||||
flavorDimensions "store"
|
||||
|
||||
productFlavors {
|
||||
universal {
|
||||
dimension "splitting"
|
||||
play {
|
||||
// Google Play
|
||||
dimension "store"
|
||||
ext.useCrashlytics = true
|
||||
// generate a universal APK without x86 native libs
|
||||
ext.useNdkAbiFilters = true
|
||||
}
|
||||
|
||||
byAbi {
|
||||
dimension "splitting"
|
||||
izzy {
|
||||
// IzzyOnDroid
|
||||
// check offending libraries with `scanapk`
|
||||
// cf https://android.izzysoft.de/articles/named/app-modules-2
|
||||
dimension "store"
|
||||
ext.useCrashlytics = false
|
||||
// generate APK by ABI, but NDK ABI filters are incompatible with split APK generation
|
||||
ext.useNdkAbiFilters = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -108,14 +113,16 @@ android {
|
|||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
def runTasks = gradle.startParameter.taskNames.toString().toLowerCase()
|
||||
if (runTasks.contains("universal")) {
|
||||
release {
|
||||
// specify architectures, to specifically exclude native libs for x86,
|
||||
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
|
||||
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
android.productFlavors.each { flavor ->
|
||||
def tasks = gradle.startParameter.taskNames.toString().toLowerCase()
|
||||
if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) {
|
||||
release {
|
||||
// specify architectures, to specifically exclude native libs for x86,
|
||||
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
|
||||
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -132,7 +139,7 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.3'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
|
@ -150,3 +157,12 @@ dependencies {
|
|||
|
||||
compileOnly rootProject.findProject(':streams_channel')
|
||||
}
|
||||
|
||||
android.productFlavors.each { flavor ->
|
||||
def tasks = gradle.startParameter.taskRequests.toString().toLowerCase()
|
||||
if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) {
|
||||
println("Building flavor with Crashlytics [${flavor.name}] - applying plugin")
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'com.google.firebase.crashlytics'
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스 [Debug]</string>
|
||||
</resources>
|
|
@ -17,7 +17,6 @@ import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
|||
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
|
||||
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
@ -155,12 +154,11 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
|
||||
private inner class ServiceHandler(looper: Looper) : Handler(looper) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
val context = this@AnalysisService
|
||||
val data = msg.data
|
||||
when (data.getString(KEY_COMMAND)) {
|
||||
COMMAND_START -> {
|
||||
runBlocking {
|
||||
context.runOnUiThread {
|
||||
FlutterUtils.runOnUiThread {
|
||||
val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
|
||||
backgroundChannel?.invokeMethod(
|
||||
"start", hashMapOf(
|
||||
|
@ -174,7 +172,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
|
|||
COMMAND_STOP -> {
|
||||
// unconditionally stop the service
|
||||
runBlocking {
|
||||
context.runOnUiThread {
|
||||
FlutterUtils.runOnUiThread {
|
||||
backgroundChannel?.invokeMethod("stop", null)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -311,7 +311,10 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
var errorStreamHandler: ErrorStreamHandler? = null
|
||||
|
||||
fun notifyError(error: String) = errorStreamHandler?.notifyError(error)
|
||||
suspend fun notifyError(error: String) {
|
||||
Log.e(LOG_TAG, "notifyError error=$error")
|
||||
errorStreamHandler?.notifyError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,10 +8,10 @@ import android.database.Cursor
|
|||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.text.format.DateFormat
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.ContextUtils.resourceUri
|
||||
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
@ -79,10 +79,11 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
|
||||
return suspendCoroutine { cont ->
|
||||
GlobalScope.launch {
|
||||
context.runOnUiThread {
|
||||
FlutterUtils.runOnUiThread {
|
||||
backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
|
||||
"query" to query,
|
||||
"locale" to Locale.getDefault().toString(),
|
||||
"use24hour" to DateFormat.is24HourFormat(context),
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("unchecked_cast")
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
|
@ -15,8 +16,10 @@ class ErrorStreamHandler : EventChannel.StreamHandler {
|
|||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
fun notifyError(error: String) {
|
||||
eventSink?.success(error)
|
||||
suspend fun notifyError(error: String) {
|
||||
FlutterUtils.runOnUiThread {
|
||||
eventSink?.success(error)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -49,7 +49,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
}
|
||||
}
|
||||
|
||||
private fun requestDirectoryAccess() {
|
||||
private suspend fun requestDirectoryAccess() {
|
||||
val path = args["path"] as String?
|
||||
if (path == null) {
|
||||
error("requestDirectoryAccess-args", "failed because of missing arguments", null)
|
||||
|
|
|
@ -19,7 +19,6 @@ import com.bumptech.glide.module.LibraryGlideModule
|
|||
import com.bumptech.glide.signature.ObjectKey
|
||||
import deckers.thibault.aves.metadata.MultiTrackMedia
|
||||
|
||||
|
||||
@GlideModule
|
||||
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.decoder
|
|||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.Priority
|
||||
import com.bumptech.glide.Registry
|
||||
|
@ -58,16 +57,13 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
|
|||
try {
|
||||
var bytes = retriever.embeddedPicture
|
||||
if (bytes == null) {
|
||||
// try to match the thumbnails returned by the content resolver / Media Store
|
||||
// the following strategies are from empirical evidence from a few test devices:
|
||||
// - API 29: sync frame closest to the middle
|
||||
// - API 26/27: default representative frame at any time position
|
||||
// there is no consistent strategy across devices to match
|
||||
// the thumbnails returned by the content resolver / Media Store
|
||||
// so we derive one in an arbitrary way
|
||||
var timeMillis: Long? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||
if (durationMillis != null) {
|
||||
timeMillis = durationMillis / 2
|
||||
}
|
||||
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||
if (durationMillis != null) {
|
||||
timeMillis = if (durationMillis < 15000) 0 else 15000
|
||||
}
|
||||
val frame = if (timeMillis != null) {
|
||||
retriever.getFrameAtTime(timeMillis * 1000)
|
||||
|
|
|
@ -9,7 +9,6 @@ import android.net.Uri
|
|||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
|
@ -734,7 +733,7 @@ abstract class ImageProvider {
|
|||
targetUri: Uri,
|
||||
targetPath: String
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaUriPermissionGranted(context, targetUri, mimeType)) {
|
||||
if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
|
||||
val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
|
||||
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
|
||||
} else {
|
||||
|
@ -758,14 +757,17 @@ abstract class ImageProvider {
|
|||
// used when skipping a move/creation op because the target file already exists
|
||||
val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean {
|
||||
val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType)
|
||||
|
||||
val pid = Binder.getCallingPid()
|
||||
val uid = Binder.getCallingUid()
|
||||
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED
|
||||
val pid = Binder.getCallingPid()
|
||||
val uid = Binder.getCallingUid()
|
||||
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||
|
@ -226,47 +225,42 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
return found
|
||||
}
|
||||
|
||||
private fun hasEntry(context: Context, contentUri: Uri): Boolean {
|
||||
var found = false
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
if (cursor != null) {
|
||||
while (cursor.moveToNext()) {
|
||||
found = true
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
|
||||
|
||||
// `uri` is a media URI, not a document URI
|
||||
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
|
||||
if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
&& isMediaUriPermissionGranted(activity, uri, mimeType))
|
||||
) {
|
||||
// if the file is on SD card, calling the content resolver `delete()`
|
||||
// removes the entry from the Media Store but it doesn't delete the file,
|
||||
// even when the app has the permission, so we manually delete the document file
|
||||
path ?: throw Exception("failed to delete file because path is null")
|
||||
if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) {
|
||||
path ?: throw Exception("failed to delete file because path is null")
|
||||
|
||||
val file = File(path)
|
||||
if (file.exists()) {
|
||||
if (StorageUtils.canEditByFile(activity, path)) {
|
||||
Log.d(LOG_TAG, "delete file at uri=$uri path=$path")
|
||||
if (file.delete()) {
|
||||
scanObsoletePath(activity, path, mimeType)
|
||||
return
|
||||
}
|
||||
} else if (!isMediaUriPermissionGranted(activity, uri, mimeType)
|
||||
&& StorageUtils.requireAccessPermission(activity, path)
|
||||
) {
|
||||
// if the file is on SD card, calling the content resolver `delete()`
|
||||
// removes the entry from the Media Store but it doesn't delete the file,
|
||||
// even when the app has the permission, so we manually delete the document file
|
||||
Log.d(LOG_TAG, "delete document at uri=$uri path=$path")
|
||||
val df = StorageUtils.getDocumentFile(activity, path, uri)
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
if (df != null && df.delete()) return
|
||||
throw Exception("failed to delete file with df=$df")
|
||||
if (df != null && df.delete()) {
|
||||
scanObsoletePath(activity, path, mimeType)
|
||||
return
|
||||
}
|
||||
throw Exception("failed to delete document with df=$df")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(LOG_TAG, "delete content at uri=$uri")
|
||||
Log.d(LOG_TAG, "delete content at uri=$uri path=$path")
|
||||
if (activity.contentResolver.delete(uri, null, null) > 0) return
|
||||
throw Exception("failed to delete row from content provider")
|
||||
} catch (securityException: SecurityException) {
|
||||
// even if the app has access permission granted on the containing directory,
|
||||
// the delete request may yield a `RecoverableSecurityException` on Android 10+
|
||||
|
@ -291,7 +285,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
throw securityException
|
||||
}
|
||||
}
|
||||
throw Exception("failed to delete row from content provider")
|
||||
}
|
||||
|
||||
override suspend fun moveMultiple(
|
||||
|
@ -330,6 +323,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// with a path, and retrieve its content URI, but:
|
||||
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
|
||||
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
|
||||
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
|
||||
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
|
||||
// - 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
|
||||
|
@ -513,31 +507,30 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
): FieldMap {
|
||||
val oldFile = File(oldPath)
|
||||
val newFile = File(oldFile.parent, newFileName)
|
||||
if (oldFile == newFile) {
|
||||
// nothing to do
|
||||
return skippedFieldMap
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
&& isMediaUriPermissionGranted(activity, oldMediaUri, mimeType)
|
||||
) {
|
||||
renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile)
|
||||
} else {
|
||||
renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile)
|
||||
return when {
|
||||
oldFile == newFile -> skippedFieldMap
|
||||
StorageUtils.canEditByFile(activity, oldPath) -> renameSingleByFile(activity, mimeType, oldPath, newFile)
|
||||
isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) -> renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile)
|
||||
else -> renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private suspend fun renameSingleByMediaStore(
|
||||
activity: Activity,
|
||||
mimeType: String,
|
||||
mediaUri: Uri,
|
||||
newFile: File
|
||||
): FieldMap {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
throw Exception("unsupported Android version")
|
||||
}
|
||||
|
||||
val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType)
|
||||
|
||||
// `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME`
|
||||
val tempValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) }
|
||||
val tempValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
if (activity.contentResolver.update(uri, tempValues, null, null) == 0) {
|
||||
throw Exception("failed to update fields for uri=$uri")
|
||||
}
|
||||
|
@ -567,39 +560,25 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false
|
||||
if (!renamed) {
|
||||
throw Exception("failed to rename entry at path=$oldPath")
|
||||
throw Exception("failed to rename document at path=$oldPath")
|
||||
}
|
||||
|
||||
// Renaming may be successful and the file at the old path no longer exists
|
||||
// but, in some situations, scanning the old path does not clear the Media Store entry.
|
||||
// For higher chance of accurate obsolete item check, keep this order:
|
||||
// 1) scan obsolete item,
|
||||
// 2) scan current item,
|
||||
// 3) check obsolete item in Media Store
|
||||
|
||||
scanObsoletePath(activity, oldPath, mimeType)
|
||||
val newFields = scanNewPath(activity, newFile.path, mimeType)
|
||||
return scanNewPath(activity, newFile.path, mimeType)
|
||||
}
|
||||
|
||||
if (hasEntry(activity, oldMediaUri)) {
|
||||
Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFile=$newFile did not clear the MediaStore entry for obsolete path=$oldPath")
|
||||
|
||||
// On Android Q (emulator/Mi9TPro), the concept of owner package disrupts renaming and the Media Store keeps an obsolete entry,
|
||||
// but we use legacy external storage, so at least we do not have to deal with a `RecoverableSecurityException`
|
||||
// when deleting this obsolete entry which is not backed by a file anymore.
|
||||
// On Android R (S10e), everything seems fine!
|
||||
// On Android S (emulator), renaming always leaves an obsolete entry whatever the owner package,
|
||||
// and we get a `RecoverableSecurityException` if we attempt to delete this obsolete entry,
|
||||
// but the entry seems to be cleaned later automatically by the Media Store anyway.
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
|
||||
try {
|
||||
delete(activity, oldMediaUri, oldPath, mimeType)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e)
|
||||
}
|
||||
}
|
||||
private suspend fun renameSingleByFile(
|
||||
activity: Activity,
|
||||
mimeType: String,
|
||||
oldPath: String,
|
||||
newFile: File
|
||||
): FieldMap {
|
||||
Log.d(LOG_TAG, "rename file at path=$oldPath")
|
||||
val renamed = File(oldPath).renameTo(newFile)
|
||||
if (!renamed) {
|
||||
throw Exception("failed to rename file at path=$oldPath")
|
||||
}
|
||||
|
||||
return newFields
|
||||
scanObsoletePath(activity, oldPath, mimeType)
|
||||
return scanNewPath(activity, newFile.path, mimeType)
|
||||
}
|
||||
|
||||
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
|
@ -658,30 +637,29 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
return null
|
||||
}
|
||||
|
||||
if (newUri == null) {
|
||||
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
|
||||
return@scanFile
|
||||
}
|
||||
|
||||
var contentUri: Uri? = null
|
||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||
val contentId = newUri.tryParseId()
|
||||
if (contentId != null) {
|
||||
if (isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
|
||||
} else if (isVideo(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
|
||||
if (newUri != null) {
|
||||
var contentUri: Uri? = null
|
||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||
val contentId = newUri.tryParseId()
|
||||
if (contentId != null) {
|
||||
if (isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
|
||||
} else if (isVideo(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
|
||||
val newFields = scanUri(contentUri) ?: scanUri(newUri)
|
||||
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
|
||||
val newFields = scanUri(contentUri) ?: scanUri(newUri)
|
||||
|
||||
if (newFields != null) {
|
||||
cont.resume(newFields)
|
||||
if (newFields != null) {
|
||||
cont.resume(newFields)
|
||||
} else {
|
||||
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
|
||||
}
|
||||
} else {
|
||||
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
|
||||
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,6 @@ import android.app.Service
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
object ContextUtils {
|
||||
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
|
||||
|
@ -20,19 +16,6 @@ object ContextUtils {
|
|||
.build()
|
||||
}
|
||||
|
||||
suspend fun Context.runOnUiThread(r: Runnable) {
|
||||
if (Looper.myLooper() != mainLooper) {
|
||||
suspendCoroutine<Boolean> { cont ->
|
||||
Handler(mainLooper).post {
|
||||
r.run()
|
||||
cont.resume(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.run()
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.isMyServiceRunning(serviceClass: Class<out Service>): Boolean {
|
||||
val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
|
||||
am ?: return false
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
object FlutterUtils {
|
||||
private val LOG_TAG = LogUtils.createTag<FlutterUtils>()
|
||||
|
@ -20,7 +23,7 @@ object FlutterUtils {
|
|||
}
|
||||
|
||||
lateinit var flutterLoader: FlutterLoader
|
||||
context.runOnUiThread {
|
||||
FlutterUtils.runOnUiThread {
|
||||
// initialization must happen on the main thread
|
||||
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
|
||||
startInitialization(context)
|
||||
|
@ -39,11 +42,25 @@ object FlutterUtils {
|
|||
flutterLoader.findAppBundlePath(),
|
||||
callbackInfo
|
||||
)
|
||||
context.runOnUiThread {
|
||||
runOnUiThread {
|
||||
val engine = FlutterEngine(context).apply {
|
||||
dartExecutor.executeDartCallback(args)
|
||||
}
|
||||
engineSetter(engine)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun runOnUiThread(r: Runnable) {
|
||||
val mainLooper = Looper.getMainLooper()
|
||||
if (Looper.myLooper() != mainLooper) {
|
||||
suspendCoroutine<Boolean> { cont ->
|
||||
Handler(mainLooper).post {
|
||||
r.run()
|
||||
cont.resume(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.run()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -137,6 +137,8 @@ object MimeTypes {
|
|||
|
||||
// extensions
|
||||
|
||||
// among other refs:
|
||||
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
||||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||
ARW -> ".arw"
|
||||
AVI, AVI_VND -> ".avi"
|
||||
|
|
|
@ -31,13 +31,18 @@ object PermissionManager {
|
|||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
|
||||
suspend fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
|
||||
Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
|
||||
|
||||
var intent: Intent? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
|
||||
val storageVolume = sm?.getStorageVolume(File(path))
|
||||
if (storageVolume != null) {
|
||||
intent = storageVolume.createOpenDocumentTreeIntent()
|
||||
} else {
|
||||
MainActivity.notifyError("failed to get storage volume for path=$path on volumes=${sm?.storageVolumes?.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to basic open document tree intent
|
||||
|
@ -49,7 +54,7 @@ object PermissionManager {
|
|||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
|
||||
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
|
||||
} else {
|
||||
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
|
||||
MainActivity.notifyError("failed to resolve activity for intent=$intent")
|
||||
onDenied()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -394,6 +394,8 @@ object StorageUtils {
|
|||
* Misc
|
||||
*/
|
||||
|
||||
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
|
||||
|
||||
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">아베스</string>
|
||||
<string name="search_shortcut_short_label">검색</string>
|
||||
|
|
10
android/app/src/main/res/values-ru/strings.xml
Normal file
10
android/app/src/main/res/values-ru/strings.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Aves</string>
|
||||
<string name="search_shortcut_short_label">Поиск</string>
|
||||
<string name="videos_shortcut_short_label">Видео</string>
|
||||
<string name="analysis_channel_name">Сканировать медия</string>
|
||||
<string name="analysis_service_description">Сканировать изображения и видео</string>
|
||||
<string name="analysis_notification_default_title">Сканирование медиа</string>
|
||||
<string name="analysis_notification_action_stop">Стоп</string>
|
||||
</resources>
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스 [Profile]</string>
|
||||
</resources>
|
|
@ -6,8 +6,9 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.0.2'
|
||||
classpath 'com.android.tools.build:gradle:7.0.3'
|
||||
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.7.1'
|
||||
}
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
# Terms of Service
|
||||
Aves is an open-source gallery and metadata explorer app allowing you to access and manage your local photos.
|
||||
## Terms of Service
|
||||
|
||||
“Aves Gallery” is an open-source gallery and metadata explorer app allowing you to access and manage your local photos and videos.
|
||||
|
||||
You must use the app for legal, authorized and acceptable purposes.
|
||||
|
||||
# Disclaimer
|
||||
This app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
|
||||
## Disclaimer
|
||||
|
||||
# Privacy policy
|
||||
Aves does not collect any personal data in its standard use. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.
|
||||
The app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
|
||||
|
||||
__We collect anonymous data to improve the app.__ We use Google Firebase for Crash Reporting, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data.
|
||||
## Privacy Policy
|
||||
|
||||
## Links
|
||||
[Sources](https://github.com/deckerst/aves)
|
||||
The app does not collect any personal data. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.
|
||||
|
||||
[License](https://github.com/deckerst/aves/blob/main/LICENSE)
|
||||
__Optionally, with your consent, the app accesses the inventory of installed apps__ to improve album display.
|
||||
|
||||
__Optionally, with your consent, the app collects anonymous error and diagnostic data__ to improve the app quality. We use Firebase Crashlytics, and the anonymous data are stored on their servers. Please note that those are anonymous data, there is absolutely nothing personal about those data.
|
||||
|
||||
## Contact
|
||||
|
||||
Developer: Thibault Deckers
|
||||
|
||||
Email: [gallery.aves@gmail.com](mailto:gallery.aves@gmail.com)
|
||||
|
||||
Website: [https://github.com/deckerst/aves](https://github.com/deckerst/aves)
|
||||
|
|
|
@ -7,4 +7,4 @@
|
|||
preferred-supported-locales:
|
||||
- en
|
||||
|
||||
# untranslated-messages-file: untranslated.json
|
||||
untranslated-messages-file: untranslated.json
|
||||
|
|
5
lib/app_flavor.dart
Normal file
5
lib/app_flavor.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
enum AppFlavor { play, izzy }
|
||||
|
||||
extension ExtraAppFlavor on AppFlavor {
|
||||
bool get canEnableErrorReporting => this == AppFlavor.play;
|
||||
}
|
|
@ -3,6 +3,8 @@ enum AppMode { main, pickExternal, pickInternal, view }
|
|||
extension ExtraAppMode on AppMode {
|
||||
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal;
|
||||
|
||||
bool get canSelect => this == AppMode.main;
|
||||
|
||||
bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
|
||||
|
||||
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
"@appName": {},
|
||||
"welcomeMessage": "Welcome to Aves",
|
||||
"@welcomeMessage": {},
|
||||
"welcomeCrashReportToggle": "Allow anonymous error reporting (optional)",
|
||||
"@welcomeCrashReportToggle": {},
|
||||
"welcomeOptional": "Optional",
|
||||
"welcomeTermsToggle": "I agree to the terms and conditions",
|
||||
"@welcomeTermsToggle": {},
|
||||
"itemCount": "{count, plural, =1{1 item} other{{count} items}}",
|
||||
|
@ -176,6 +175,25 @@
|
|||
"@coordinateFormatDms": {},
|
||||
"coordinateFormatDecimal": "Decimal degrees",
|
||||
"@coordinateFormatDecimal": {},
|
||||
"coordinateDms": "{coordinate} {direction}",
|
||||
"@coordinateDms": {
|
||||
"placeholders": {
|
||||
"coordinate": {
|
||||
"type": "String"
|
||||
},
|
||||
"direction": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"coordinateDmsNorth": "N",
|
||||
"@coordinateDmsNorth": {},
|
||||
"coordinateDmsSouth": "S",
|
||||
"@coordinateDmsSouth": {},
|
||||
"coordinateDmsEast": "E",
|
||||
"@coordinateDmsEast": {},
|
||||
"coordinateDmsWest": "W",
|
||||
"@coordinateDmsWest": {},
|
||||
|
||||
"unitSystemMetric": "Metric",
|
||||
"@unitSystemMetric": {},
|
||||
|
@ -289,6 +307,18 @@
|
|||
}
|
||||
},
|
||||
|
||||
"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": {
|
||||
"count": {},
|
||||
"types": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.",
|
||||
"@nameConflictDialogSingleSourceMessage": {},
|
||||
"nameConflictDialogMultipleSourceMessage": "Some files have the same name.",
|
||||
|
@ -311,6 +341,17 @@
|
|||
}
|
||||
},
|
||||
|
||||
"videoResumeDialogMessage": "Do you want to resume playing at {time}?",
|
||||
"@videoResumeDialogMessage": {
|
||||
"placeholders": {
|
||||
"time": {}
|
||||
}
|
||||
},
|
||||
"videoStartOverButtonLabel": "START OVER",
|
||||
"@videoStartOverButtonLabel": {},
|
||||
"videoResumeButtonLabel": "RESUME",
|
||||
"@videoResumeButtonLabel": {},
|
||||
|
||||
"setCoverDialogTitle": "Set Cover",
|
||||
"@setCoverDialogTitle": {},
|
||||
"setCoverDialogLatest": "Latest item",
|
||||
|
@ -360,6 +401,8 @@
|
|||
"@editEntryDateDialogSet": {},
|
||||
"editEntryDateDialogShift": "Shift",
|
||||
"@editEntryDateDialogShift": {},
|
||||
"editEntryDateDialogExtractFromTitle": "Extract from title",
|
||||
"@editEntryDateDialogExtractFromTitle": {},
|
||||
"editEntryDateDialogClear": "Clear",
|
||||
"@editEntryDateDialogClear": {},
|
||||
"editEntryDateDialogFieldSelection": "Field selection",
|
||||
|
@ -374,7 +417,7 @@
|
|||
"removeEntryMetadataDialogMore": "More",
|
||||
"@removeEntryMetadataDialogMore": {},
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside this motion photo. Are you sure you want to remove it?",
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo. Are you sure you want to remove it?",
|
||||
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
|
||||
|
||||
"videoSpeedDialogLabel": "Playback speed",
|
||||
|
@ -419,6 +462,8 @@
|
|||
"@aboutLinkSources": {},
|
||||
"aboutLinkLicense": "License",
|
||||
"@aboutLinkLicense": {},
|
||||
"aboutLinkPolicy": "Privacy Policy",
|
||||
"@aboutLinkPolicy": {},
|
||||
|
||||
"aboutUpdate": "New Version Available",
|
||||
"@aboutUpdate": {},
|
||||
|
@ -454,6 +499,8 @@
|
|||
"@aboutCreditsWorldAtlas1": {},
|
||||
"aboutCreditsWorldAtlas2": "under ISC License.",
|
||||
"@aboutCreditsWorldAtlas2": {},
|
||||
"aboutCreditsTranslators": "Translators:",
|
||||
"@aboutCreditsTranslators": {},
|
||||
|
||||
"aboutLicenses": "Open-Source Licenses",
|
||||
"@aboutLicenses": {},
|
||||
|
@ -470,6 +517,9 @@
|
|||
"aboutLicensesShowAllButtonLabel": "Show All Licenses",
|
||||
"@aboutLicensesShowAllButtonLabel": {},
|
||||
|
||||
"policyPageTitle": "Privacy Policy",
|
||||
"@policyPageTitle": {},
|
||||
|
||||
"collectionPageTitle": "Collection",
|
||||
"@collectionPageTitle": {},
|
||||
"collectionPickPageTitle": "Pick",
|
||||
|
@ -481,6 +531,10 @@
|
|||
}
|
||||
},
|
||||
|
||||
"collectionActionShowTitleSearch": "Show title filter",
|
||||
"@collectionActionShowTitleSearch": {},
|
||||
"collectionActionHideTitleSearch": "Hide title filter",
|
||||
"@collectionActionHideTitleSearch": {},
|
||||
"collectionActionAddShortcut": "Add shortcut",
|
||||
"@collectionActionAddShortcut": {},
|
||||
"collectionActionCopy": "Copy to album",
|
||||
|
@ -489,6 +543,11 @@
|
|||
"@collectionActionMove": {},
|
||||
"collectionActionRescan": "Rescan",
|
||||
"@collectionActionRescan": {},
|
||||
"collectionActionEdit": "Edit",
|
||||
"@collectionActionEdit": {},
|
||||
|
||||
"collectionSearchTitlesHintText": "Search titles",
|
||||
"@collectionSearchTitlesHintText": {},
|
||||
|
||||
"collectionSortTitle": "Sort",
|
||||
"@collectionSortTitle": {},
|
||||
|
@ -536,6 +595,12 @@
|
|||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionEditFailureFeedback": "{count, plural, =1{Failed to edit 1 item} other{Failed to edit {count} items}}",
|
||||
"@collectionEditFailureFeedback": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionExportFailureFeedback": "{count, plural, =1{Failed to export 1 page} other{Failed to export {count} pages}}",
|
||||
"@collectionExportFailureFeedback": {
|
||||
"placeholders": {
|
||||
|
@ -554,6 +619,12 @@
|
|||
"count": {}
|
||||
}
|
||||
},
|
||||
"collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}",
|
||||
"@collectionEditSuccessFeedback": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
|
||||
"collectionEmptyFavourites": "No favourites",
|
||||
"@collectionEmptyFavourites": {},
|
||||
|
@ -705,10 +776,16 @@
|
|||
"settingsThumbnailShowVideoDuration": "Show video duration",
|
||||
"@settingsThumbnailShowVideoDuration": {},
|
||||
|
||||
"settingsCollectionSelectionQuickActionsTile": "Quick actions for item selection",
|
||||
"@settingsCollectionSelectionQuickActionsTile": {},
|
||||
"settingsCollectionSelectionQuickActionEditorTitle": "Quick Actions",
|
||||
"@settingsCollectionSelectionQuickActionEditorTitle": {},
|
||||
"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": {},
|
||||
|
||||
|
@ -794,8 +871,12 @@
|
|||
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
"@settingsSectionPrivacy": {},
|
||||
"settingsEnableErrorReporting": "Allow anonymous error reporting",
|
||||
"@settingsEnableErrorReporting": {},
|
||||
"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": {},
|
||||
|
||||
|
@ -856,18 +937,6 @@
|
|||
|
||||
"statsPageTitle": "Stats",
|
||||
"@statsPageTitle": {},
|
||||
"statsImage": "{count, plural, =1{image} other{images}}",
|
||||
"@statsImage": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"statsVideo": "{count, plural, =1{video} other{videos}}",
|
||||
"@statsVideo": {
|
||||
"placeholders": {
|
||||
"count": {}
|
||||
}
|
||||
},
|
||||
"statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}",
|
||||
"@statsWithGps": {
|
||||
"placeholders": {
|
||||
|
@ -883,8 +952,6 @@
|
|||
|
||||
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
|
||||
"@viewerOpenPanoramaButtonLabel": {},
|
||||
"viewerOpenTooltip": "Open",
|
||||
"@viewerOpenTooltip": {},
|
||||
"viewerErrorUnknown": "Oops!",
|
||||
"@viewerErrorUnknown": {},
|
||||
"viewerErrorDoesNotExist": "The file no longer exists.",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"appName": "아베스",
|
||||
"welcomeMessage": "아베스 사용을 환영합니다",
|
||||
"welcomeCrashReportToggle": "오류 보고서를 보내는 것에 동의합니다 (선택)",
|
||||
"welcomeOptional": "선택",
|
||||
"welcomeTermsToggle": "이용약관에 동의합니다",
|
||||
"itemCount": "{count, plural, other{{count}개}}",
|
||||
|
||||
|
@ -87,6 +87,11 @@
|
|||
|
||||
"coordinateFormatDms": "도분초",
|
||||
"coordinateFormatDecimal": "소수점",
|
||||
"coordinateDms": "{direction} {coordinate}",
|
||||
"coordinateDmsNorth": "북위",
|
||||
"coordinateDmsSouth": "남위",
|
||||
"coordinateDmsEast": "동경",
|
||||
"coordinateDmsWest": "서경",
|
||||
|
||||
"unitSystemMetric": "미터법",
|
||||
"unitSystemImperial": "야드파운드법",
|
||||
|
@ -130,6 +135,9 @@
|
|||
"notEnoughSpaceDialogTitle": "저장공간 부족",
|
||||
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
|
||||
|
||||
"unsupportedTypeDialogTitle": "미지원 형식",
|
||||
"unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}",
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.",
|
||||
"nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.",
|
||||
|
||||
|
@ -141,6 +149,10 @@
|
|||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
|
||||
|
||||
"videoResumeDialogMessage": "{time}부터 재개하시겠습니까?",
|
||||
"videoStartOverButtonLabel": "처음부터",
|
||||
"videoResumeButtonLabel": "재개",
|
||||
|
||||
"setCoverDialogTitle": "대표 이미지 변경",
|
||||
"setCoverDialogLatest": "최근 항목",
|
||||
"setCoverDialogCustom": "직접 설정",
|
||||
|
@ -165,6 +177,7 @@
|
|||
"editEntryDateDialogTitle": "날짜 및 시간",
|
||||
"editEntryDateDialogSet": "편집",
|
||||
"editEntryDateDialogShift": "시간 이동",
|
||||
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
|
||||
"editEntryDateDialogClear": "삭제",
|
||||
"editEntryDateDialogFieldSelection": "필드 선택",
|
||||
"editEntryDateDialogHours": "시간",
|
||||
|
@ -173,7 +186,7 @@
|
|||
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||
"removeEntryMetadataDialogMore": "더 보기",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
|
||||
|
||||
"videoSpeedDialogLabel": "재생 배속",
|
||||
|
||||
|
@ -198,6 +211,7 @@
|
|||
"aboutPageTitle": "앱 정보",
|
||||
"aboutLinkSources": "소스 코드",
|
||||
"aboutLinkLicense": "라이선스",
|
||||
"aboutLinkPolicy": "개인정보 보호정책",
|
||||
|
||||
"aboutUpdate": "업데이트 사용 가능",
|
||||
"aboutUpdateLinks1": "앱의 최신 버전을",
|
||||
|
@ -217,6 +231,7 @@
|
|||
"aboutCredits": "크레딧",
|
||||
"aboutCreditsWorldAtlas1": "이 앱은",
|
||||
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
|
||||
"aboutCreditsTranslators": "번역가:",
|
||||
|
||||
"aboutLicenses": "오픈 소스 라이선스",
|
||||
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
|
||||
|
@ -226,14 +241,21 @@
|
|||
"aboutLicensesDartPackages": "다트 패키지",
|
||||
"aboutLicensesShowAllButtonLabel": "라이선스 모두 보기",
|
||||
|
||||
"policyPageTitle": "개인정보 보호정책",
|
||||
|
||||
"collectionPageTitle": "미디어",
|
||||
"collectionPickPageTitle": "항목 선택",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}",
|
||||
|
||||
"collectionActionShowTitleSearch": "제목 필터 보기",
|
||||
"collectionActionHideTitleSearch": "제목 필터 숨기기",
|
||||
"collectionActionAddShortcut": "홈 화면에 추가",
|
||||
"collectionActionCopy": "앨범으로 복사",
|
||||
"collectionActionMove": "앨범으로 이동",
|
||||
"collectionActionRescan": "새로 분석",
|
||||
"collectionActionEdit": "편집",
|
||||
|
||||
"collectionSearchTitlesHintText": "제목 검색",
|
||||
|
||||
"collectionSortTitle": "정렬",
|
||||
"collectionSortDate": "날짜",
|
||||
|
@ -253,9 +275,11 @@
|
|||
"collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}",
|
||||
"collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}",
|
||||
"collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}",
|
||||
"collectionEditFailureFeedback": "{count, plural, other{항목 {count}개를 편집하지 못했습니다}}",
|
||||
"collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}",
|
||||
"collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}",
|
||||
"collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}",
|
||||
"collectionEditSuccessFeedback": "{count, plural, other{항목 {count}개를 편집했습니다}}",
|
||||
|
||||
"collectionEmptyFavourites": "즐겨찾기가 없습니다",
|
||||
"collectionEmptyVideos": "동영상이 없습니다",
|
||||
|
@ -340,8 +364,11 @@
|
|||
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
|
||||
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
|
||||
|
||||
"settingsCollectionSelectionQuickActionsTile": "항목 선택의 빠른 작업",
|
||||
"settingsCollectionSelectionQuickActionEditorTitle": "빠른 작업",
|
||||
"settingsCollectionQuickActionsTile": "빠른 작업",
|
||||
"settingsCollectionQuickActionEditorTitle": "빠른 작업",
|
||||
"settingsCollectionQuickActionTabBrowsing": "탐색 시",
|
||||
"settingsCollectionQuickActionTabSelecting": "선택 시",
|
||||
"settingsCollectionBrowsingQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 탐색할 때 표시될 버튼을 선택하세요.",
|
||||
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
||||
|
||||
"settingsSectionViewer": "뷰어",
|
||||
|
@ -387,7 +414,9 @@
|
|||
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
|
||||
|
||||
"settingsSectionPrivacy": "개인정보 보호",
|
||||
"settingsEnableErrorReporting": "오류 보고서 보내기",
|
||||
"settingsAllowInstalledAppAccess": "설치된 앱의 목록 접근 허용",
|
||||
"settingsAllowInstalledAppAccessSubtitle": "앨범 표시 개선을 위해",
|
||||
"settingsAllowErrorReporting": "오류 보고서 보내기",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
|
||||
"settingsHiddenFiltersTile": "숨겨진 필터",
|
||||
|
@ -421,15 +450,12 @@
|
|||
"settingsUnitSystemTitle": "단위법",
|
||||
|
||||
"statsPageTitle": "통계",
|
||||
"statsImage": "{count, plural, other{사진}}",
|
||||
"statsVideo": "{count, plural, other{동영상}}",
|
||||
"statsWithGps": "{count, plural, other{{count}개 위치가 있음}}",
|
||||
"statsTopCountries": "국가 랭킹",
|
||||
"statsTopPlaces": "장소 랭킹",
|
||||
"statsTopTags": "태그 랭킹",
|
||||
|
||||
"viewerOpenPanoramaButtonLabel": "파노라마 열기",
|
||||
"viewerOpenTooltip": "열기",
|
||||
"viewerErrorUnknown": "아이구!",
|
||||
"viewerErrorDoesNotExist": "파일이 존재하지 않습니다.",
|
||||
|
||||
|
|
503
lib/l10n/app_ru.arb
Normal file
503
lib/l10n/app_ru.arb
Normal file
|
@ -0,0 +1,503 @@
|
|||
{
|
||||
"appName": "Aves",
|
||||
"welcomeMessage": "Добро пожаловать в Aves",
|
||||
"welcomeOptional": "Опционально",
|
||||
"welcomeTermsToggle": "Я согласен с условиями и положениями",
|
||||
"itemCount": "{count, plural, =1{1 объект} few{{count} объекта} other{{count} объектов}}",
|
||||
|
||||
"timeSeconds": "{seconds, plural, =1{1 секунда} few{{seconds} секунды} other{{seconds} секунд}}",
|
||||
"timeMinutes": "{minutes, plural, =1{1 минута} few{{minutes} минуты} other{{minutes} минут}}",
|
||||
|
||||
"applyButtonLabel": "ПРИМЕНИТЬ",
|
||||
"deleteButtonLabel": "УДАЛИТЬ",
|
||||
"nextButtonLabel": "ДАЛЕЕ",
|
||||
"showButtonLabel": "ПОКАЗАТЬ",
|
||||
"hideButtonLabel": "СКРЫТЬ",
|
||||
"continueButtonLabel": "ПРОДОЛЖИТЬ",
|
||||
|
||||
"changeTooltip": "Изменить",
|
||||
"clearTooltip": "Очистить",
|
||||
"previousTooltip": "Предыдущий",
|
||||
"nextTooltip": "Следующий",
|
||||
"showTooltip": "Показать",
|
||||
"hideTooltip": "Скрыть",
|
||||
"removeTooltip": "Удалить",
|
||||
|
||||
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
|
||||
|
||||
"sourceStateLoading": "Загрузка",
|
||||
"sourceStateCataloguing": "Каталогизация",
|
||||
"sourceStateLocatingCountries": "Расположение стран",
|
||||
"sourceStateLocatingPlaces": "Расположение локаций",
|
||||
|
||||
"chipActionDelete": "Удалить",
|
||||
"chipActionGoToAlbumPage": "Показывать в Альбомах",
|
||||
"chipActionGoToCountryPage": "Показывать в Странах",
|
||||
"chipActionGoToTagPage": "Показывать в тегах",
|
||||
"chipActionHide": "Скрыть",
|
||||
"chipActionPin": "Закрепить",
|
||||
"chipActionUnpin": "Открепить",
|
||||
"chipActionRename": "Переименовать",
|
||||
"chipActionSetCover": "Установить обложку",
|
||||
"chipActionCreateAlbum": "Создать альбом",
|
||||
|
||||
"entryActionCopyToClipboard": "Скопировать в буфер обмена",
|
||||
"entryActionDelete": "Удалить",
|
||||
"entryActionExport": "Экспорт",
|
||||
"entryActionInfo": "Информация",
|
||||
"entryActionRename": "Переименовать",
|
||||
"entryActionRotateCCW": "Повернуть против часовой стрелки",
|
||||
"entryActionRotateCW": "Повернуть по часовой стрелки",
|
||||
"entryActionFlip": "Отразить по горизонтали",
|
||||
"entryActionPrint": "Печать",
|
||||
"entryActionShare": "Поделиться",
|
||||
"entryActionViewSource": "Посмотреть источник",
|
||||
"entryActionViewMotionPhotoVideo": "Открыть «Живые фото»",
|
||||
"entryActionEdit": "Изменить с помощью…",
|
||||
"entryActionOpen": "Открыть с помощью…",
|
||||
"entryActionSetAs": "Установить как…",
|
||||
"entryActionOpenMap": "Показать на карте…",
|
||||
"entryActionRotateScreen": "Повернуть экран",
|
||||
"entryActionAddFavourite": "Добавить в избранное",
|
||||
"entryActionRemoveFavourite": "Удалить из избранного",
|
||||
|
||||
"videoActionCaptureFrame": "Сохранить кадр",
|
||||
"videoActionPause": "Стоп",
|
||||
"videoActionPlay": "Играть",
|
||||
"videoActionReplay10": "Перемотка на 10 секунд назад",
|
||||
"videoActionSkip10": "Перемотка на 10 секунд вперёд",
|
||||
"videoActionSelectStreams": "Выбрать дорожку",
|
||||
"videoActionSetSpeed": "Скорость вопспроизведения",
|
||||
"videoActionSettings": "Настройки",
|
||||
|
||||
"entryInfoActionEditDate": "Изменить дату и время",
|
||||
"entryInfoActionRemoveMetadata": "Удалить метаданные",
|
||||
|
||||
"filterFavouriteLabel": "Избранное",
|
||||
"filterLocationEmptyLabel": "Без местоположения",
|
||||
"filterTagEmptyLabel": "Без тегов",
|
||||
"filterTypeAnimatedLabel": "GIF",
|
||||
"filterTypeMotionPhotoLabel": "Живое фото",
|
||||
"filterTypePanoramaLabel": "Панорама",
|
||||
"filterTypeRawLabel": "RAW",
|
||||
"filterTypeSphericalVideoLabel": "360° видео",
|
||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||
"filterMimeImageLabel": "Изображение",
|
||||
"filterMimeVideoLabel": "Видео",
|
||||
|
||||
"coordinateFormatDms": "Градусы, минуты и секунды",
|
||||
"coordinateFormatDecimal": "Десятичные градусы",
|
||||
"coordinateDms": "{coordinate} {direction}",
|
||||
"coordinateDmsNorth": "с. ш.",
|
||||
"coordinateDmsSouth": "ю. ш.",
|
||||
"coordinateDmsEast": "в. д.",
|
||||
"coordinateDmsWest": "з. д.",
|
||||
|
||||
"unitSystemMetric": "Метрические",
|
||||
"unitSystemImperial": "Имперские",
|
||||
|
||||
"videoLoopModeNever": "Никогда",
|
||||
"videoLoopModeShortOnly": "Только для коротких видео",
|
||||
"videoLoopModeAlways": "Всегда",
|
||||
|
||||
"mapStyleGoogleNormal": "Google Карты",
|
||||
"mapStyleGoogleHybrid": "Google Карты (Гибридный)",
|
||||
"mapStyleGoogleTerrain": "Google Карты (Местность)",
|
||||
"mapStyleOsmHot": "Команда гуманитарной картопомощи",
|
||||
"mapStyleStamenToner": "Stamen Toner",
|
||||
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
||||
|
||||
"nameConflictStrategyRename": "Переименовать",
|
||||
"nameConflictStrategyReplace": "Заменить",
|
||||
"nameConflictStrategySkip": "Пропустить",
|
||||
|
||||
"keepScreenOnNever": "Никогда",
|
||||
"keepScreenOnViewerOnly": "Только в просмотрщике",
|
||||
"keepScreenOnAlways": "Всегда",
|
||||
|
||||
"accessibilityAnimationsRemove": "Предотвратить экранные эффекты",
|
||||
"accessibilityAnimationsKeep": "Сохранить экранные эффекты",
|
||||
|
||||
"albumTierNew": "Новые",
|
||||
"albumTierPinned": "Закрепленные",
|
||||
"albumTierSpecial": "Стандартные",
|
||||
"albumTierApps": "Приложения",
|
||||
"albumTierRegular": "Другие",
|
||||
|
||||
"storageVolumeDescriptionFallbackPrimary": "Внутренняя память",
|
||||
"storageVolumeDescriptionFallbackNonPrimary": "SD-карта",
|
||||
"rootDirectoryDescription": "корень",
|
||||
"otherDirectoryDescription": "“{name}” каталог",
|
||||
"storageAccessDialogTitle": "Доступ к хранилищу",
|
||||
"storageAccessDialogMessage": "Пожалуйста, выберите каталог {directory} на накопителе «{volume}» на следующем экране, чтобы предоставить этому приложению доступ к нему.",
|
||||
"restrictedAccessDialogTitle": "Ограниченный доступ",
|
||||
"restrictedAccessDialogMessage": "Этому приложению не разрешается изменять файлы в каталоге {directory} накопителя «{volume}».\n\nПожалуйста, используйте предустановленный файловый менеджер или галерею, чтобы переместить элементы в другой каталог.",
|
||||
"notEnoughSpaceDialogTitle": "Недостаточно свободного места.",
|
||||
"notEnoughSpaceDialogMessage": "Для завершения этой операции требуется {neededSize} свободного места на «{volume}», но осталось только {freeSize}.",
|
||||
|
||||
"unsupportedTypeDialogTitle": "Неподдерживаемые форматы",
|
||||
"unsupportedTypeDialogMessage": "{count, plural, =1{Эта операция не поддерживается для объектов следующего формата: {types}.} other{Эта операция не поддерживается для объектов следующих форматов: {types}.}}",
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "Некоторые файлы в папке назначения имеют одно и то же имя.",
|
||||
"nameConflictDialogMultipleSourceMessage": "Некоторые файлы имеют одно и то же имя.",
|
||||
|
||||
"addShortcutDialogLabel": "Название ярлыка",
|
||||
"addShortcutButtonLabel": "СОЗДАТЬ",
|
||||
|
||||
"noMatchingAppDialogTitle": "Нет подходящего приложения",
|
||||
"noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.",
|
||||
|
||||
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}",
|
||||
|
||||
"videoResumeDialogMessage": "Хотите ли вы возобновить воспроизведение на {time}?",
|
||||
"videoStartOverButtonLabel": "ВОСПРОИЗВЕСТИ СНАЧАЛА",
|
||||
"videoResumeButtonLabel": "ПРОДОЛЖИТЬ",
|
||||
|
||||
"setCoverDialogTitle": "Установить обложку",
|
||||
"setCoverDialogLatest": "Последний объект",
|
||||
"setCoverDialogCustom": "Собственная",
|
||||
|
||||
"hideFilterConfirmationDialogMessage": "Соответствующие фотографии и видео будут скрыты из вашей коллекции. Вы можете показать их снова в настройках в разделе «Конфиденциальность».\n\nВы уверены, что хотите их скрыть?",
|
||||
|
||||
"newAlbumDialogTitle": "Новый альбом",
|
||||
"newAlbumDialogNameLabel": "Название альбома",
|
||||
"newAlbumDialogNameLabelAlreadyExistsHelper": "Каталог уже существует",
|
||||
"newAlbumDialogStorageLabel": "Накопитель:",
|
||||
|
||||
"renameAlbumDialogLabel": "Новое название",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Каталог уже существует",
|
||||
|
||||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот альбом и его объект?} few{Вы уверены, что хотите удалить этот альбом и его {count} объекта?} other{Вы уверены, что хотите удалить этот альбом и его {count} объектов?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить эти альбомы и их объекты?} few{Вы уверены, что хотите удалить эти альбомы и их {count} объекта?} other{Вы уверены, что хотите удалить эти альбомы и их {count} объектов?}}",
|
||||
|
||||
"exportEntryDialogFormat": "Формат:",
|
||||
|
||||
"renameEntryDialogLabel": "Новое название",
|
||||
|
||||
"editEntryDateDialogTitle": "Дата и время",
|
||||
"editEntryDateDialogSet": "Задать",
|
||||
"editEntryDateDialogShift": "Сдвиг",
|
||||
"editEntryDateDialogExtractFromTitle": "Извлечь из названия",
|
||||
"editEntryDateDialogClear": "Очистить",
|
||||
"editEntryDateDialogFieldSelection": "Выбор поля",
|
||||
"editEntryDateDialogHours": "Часов",
|
||||
"editEntryDateDialogMinutes": "Минут",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Удаление метаданных",
|
||||
"removeEntryMetadataDialogMore": "Дополнительно",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "Для воспроизведения видео внутри этой живой фотографии требуется XMP профиль. Вы уверены, что хотите удалить его?",
|
||||
|
||||
"videoSpeedDialogLabel": "Скорость воспроизведения",
|
||||
|
||||
"videoStreamSelectionDialogVideo": "Видео",
|
||||
"videoStreamSelectionDialogAudio": "Аудио",
|
||||
"videoStreamSelectionDialogText": "Субтитры",
|
||||
"videoStreamSelectionDialogOff": "Отключено",
|
||||
"videoStreamSelectionDialogTrack": "Дорожка",
|
||||
"videoStreamSelectionDialogNoSelection": "Других дорожек нет.",
|
||||
|
||||
"genericSuccessFeedback": "Выполнено!",
|
||||
"genericFailureFeedback": "Не удалось",
|
||||
|
||||
"menuActionSort": "Сортировка",
|
||||
"menuActionGroup": "Группировка",
|
||||
"menuActionSelect": "Выбрать",
|
||||
"menuActionSelectAll": "Выбрать все",
|
||||
"menuActionSelectNone": "Снять выделение",
|
||||
"menuActionMap": "Карта",
|
||||
"menuActionStats": "Статистика",
|
||||
|
||||
"aboutPageTitle": "О нас",
|
||||
"aboutLinkSources": "Исходники",
|
||||
"aboutLinkLicense": "Лицензия",
|
||||
"aboutLinkPolicy": "Политика конфиденциальности",
|
||||
|
||||
"aboutUpdate": "Доступна новая версия",
|
||||
"aboutUpdateLinks1": "Новая версия Aves доступна на",
|
||||
"aboutUpdateLinks2": "и",
|
||||
"aboutUpdateLinks3": ".",
|
||||
"aboutUpdateGitHub": "GitHub",
|
||||
"aboutUpdateGooglePlay": "Play Маркет",
|
||||
|
||||
"aboutBug": "Отчет об ошибке",
|
||||
"aboutBugSaveLogInstruction": "Сохраните логи приложения в файл",
|
||||
"aboutBugSaveLogButton": "Сохранить",
|
||||
"aboutBugCopyInfoInstruction": "Скопируйте системную информацию",
|
||||
"aboutBugCopyInfoButton": "Скопировать",
|
||||
"aboutBugReportInstruction": "Отправьте отчёт об ошибке на GitHub вместе с логами и системной информацией",
|
||||
"aboutBugReportButton": "Отправить",
|
||||
|
||||
"aboutCredits": "Благодарности",
|
||||
"aboutCreditsWorldAtlas1": "Это приложение использует файл TopoJSON из",
|
||||
"aboutCreditsWorldAtlas2": "под лицензией ISC.",
|
||||
"aboutCreditsTranslators": "Переводчики:",
|
||||
|
||||
"aboutLicenses": "Лицензии с открытым исходным кодом",
|
||||
"aboutLicensesBanner": "Это приложение использует следующие пакеты и библиотеки с открытым исходным кодом.",
|
||||
"aboutLicensesAndroidLibraries": "Библиотеки Android",
|
||||
"aboutLicensesFlutterPlugins": "Плагины Flutter",
|
||||
"aboutLicensesFlutterPackages": "Пакеты Flutter",
|
||||
"aboutLicensesDartPackages": "Пакеты Dart",
|
||||
"aboutLicensesShowAllButtonLabel": "Показать все лицензии",
|
||||
|
||||
"policyPageTitle": "Политика конфиденциальности",
|
||||
|
||||
"collectionPageTitle": "Коллекция",
|
||||
"collectionPickPageTitle": "Выбрать",
|
||||
"collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов}}",
|
||||
|
||||
"collectionActionShowTitleSearch": "Показать фильтр заголовка",
|
||||
"collectionActionHideTitleSearch": "Скрыть фильтр заголовка",
|
||||
"collectionActionAddShortcut": "Добавить ярлык",
|
||||
"collectionActionCopy": "Скопировать в альбом",
|
||||
"collectionActionMove": "Переместить в альбом",
|
||||
"collectionActionRescan": "Пересканировать",
|
||||
"collectionActionEdit": "Изменить",
|
||||
|
||||
"collectionSearchTitlesHintText": "Поиск заголовков",
|
||||
|
||||
"collectionSortTitle": "Сортировка",
|
||||
"collectionSortDate": "По дате",
|
||||
"collectionSortSize": "По размеру",
|
||||
"collectionSortName": "По имени альбома и файла",
|
||||
|
||||
"collectionGroupTitle": "Группировка",
|
||||
"collectionGroupAlbum": "По альбому",
|
||||
"collectionGroupMonth": "По месяцу",
|
||||
"collectionGroupDay": "По дню",
|
||||
"collectionGroupNone": "Не группировать",
|
||||
|
||||
"sectionUnknown": "Неизвестно",
|
||||
"dateToday": "Сегодня",
|
||||
"dateYesterday": "Вчера",
|
||||
"dateThisMonth": "В этом месяце",
|
||||
"collectionDeleteFailureFeedback": "{count, plural, =1{Не удалось удалить 1 объект} few{Не удалось удалить {count} объекта} other{Не удалось удалить {count} объектов}}",
|
||||
"collectionCopyFailureFeedback": "{count, plural, =1{Не удалось скопировать 1 объект} few{Не удалось скопировать {count} объекта} other{Не удалось скопировать {count} объектов}}",
|
||||
"collectionMoveFailureFeedback": "{count, plural, =1{Не удалось переместить 1 объект} few{Не удалось переместить {count} объекта} other{Не удалось переместить {count} объектов}}",
|
||||
"collectionEditFailureFeedback": "{count, plural, =1{Не удалось изменить 1 объект} few{Не удалось изменить {count} объекта} other{Не удалось изменить {count} объектов}}",
|
||||
"collectionExportFailureFeedback": "{count, plural, =1{Не удалось экспортировать 1 страницу} few{Не удалось экспортировать {count} страницы} other{Не удалось экспортировать {count} страниц}}",
|
||||
"collectionCopySuccessFeedback": "{count, plural, =1{Скопирован 1 объект} few{Скопировано {count} объекта} other{Скопировано {count} объектов}}",
|
||||
"collectionMoveSuccessFeedback": "{count, plural, =1{Перемещен 1 объект} few{Перемещено {count} объекта} other{Перемещено {count} объектов}}",
|
||||
"collectionEditSuccessFeedback": "{count, plural, =1{Изменён 1 объект} few{Изменено {count} объекта} other{Изменено {count} объектов}}",
|
||||
|
||||
"collectionEmptyFavourites": "Нет избранных",
|
||||
"collectionEmptyVideos": "Нет видео",
|
||||
"collectionEmptyImages": "Нет изображений",
|
||||
|
||||
"collectionSelectSectionTooltip": "Выбрать раздел",
|
||||
"collectionDeselectSectionTooltip": "Снять выбор с раздела",
|
||||
|
||||
"drawerCollectionAll": "Вся коллекция",
|
||||
"drawerCollectionFavourites": "Избранное",
|
||||
"drawerCollectionImages": "Изображения",
|
||||
"drawerCollectionVideos": "Видео",
|
||||
"drawerCollectionMotionPhotos": "Живые фото",
|
||||
"drawerCollectionPanoramas": "Панорамы",
|
||||
"drawerCollectionRaws": "RAW",
|
||||
"drawerCollectionSphericalVideos": "360° видео",
|
||||
|
||||
"chipSortTitle": "Сортировка",
|
||||
"chipSortDate": "По дате",
|
||||
"chipSortName": "По названию",
|
||||
"chipSortCount": "По количеству объектов",
|
||||
|
||||
"albumGroupTitle": "Группировка",
|
||||
"albumGroupTier": "По уровню",
|
||||
"albumGroupVolume": "По накопителю",
|
||||
"albumGroupNone": "Не группировать",
|
||||
|
||||
"albumPickPageTitleCopy": "Копировать в альбом",
|
||||
"albumPickPageTitleExport": "Экспорт в альбом",
|
||||
"albumPickPageTitleMove": "Переместить в альбом",
|
||||
"albumPickPageTitlePick": "Выберите альбом",
|
||||
|
||||
"albumCamera": "Камера",
|
||||
"albumDownload": "Загрузки",
|
||||
"albumScreenshots": "Скриншоты",
|
||||
"albumScreenRecordings": "Записи экрана",
|
||||
"albumVideoCaptures": "Видеозаписи",
|
||||
|
||||
"albumPageTitle": "Альбомы",
|
||||
"albumEmpty": "Нет альбомов",
|
||||
"createAlbumTooltip": "Создать альбом",
|
||||
"createAlbumButtonLabel": "СОЗДАТЬ",
|
||||
"newFilterBanner": "новый",
|
||||
|
||||
"countryPageTitle": "Страны",
|
||||
"countryEmpty": "Нет стран",
|
||||
|
||||
"tagPageTitle": "Теги",
|
||||
"tagEmpty": "Нет тегов",
|
||||
|
||||
"searchCollectionFieldHint": "Поиск по коллекции",
|
||||
"searchSectionRecent": "Недавние",
|
||||
"searchSectionAlbums": "Альбомы",
|
||||
"searchSectionCountries": "Страны",
|
||||
"searchSectionPlaces": "Локации",
|
||||
"searchSectionTags": "Теги",
|
||||
|
||||
"settingsPageTitle": "Настройки",
|
||||
"settingsSystemDefault": "Система",
|
||||
"settingsDefault": "По умолчанию",
|
||||
|
||||
"settingsActionExport": "Экспорт",
|
||||
"settingsActionImport": "Импорт",
|
||||
|
||||
"settingsSectionNavigation": "Навигация",
|
||||
"settingsHome": "Домашний каталог",
|
||||
"settingsKeepScreenOnTile": "Держать экран включенным",
|
||||
"settingsKeepScreenOnTitle": "Держать экран включенным",
|
||||
"settingsDoubleBackExit": "Дважды нажмите «назад», чтобы выйти",
|
||||
|
||||
"settingsNavigationDrawerTile": "Навигационное меню",
|
||||
"settingsNavigationDrawerEditorTitle": "Навигационное меню",
|
||||
"settingsNavigationDrawerBanner": "Нажмите и удерживайте, чтобы переместить и изменить порядок пунктов меню.",
|
||||
"settingsNavigationDrawerTabTypes": "Типы",
|
||||
"settingsNavigationDrawerTabAlbums": "Альбомы",
|
||||
"settingsNavigationDrawerTabPages": "Страницы",
|
||||
"settingsNavigationDrawerAddAlbum": "Добавить альбом",
|
||||
|
||||
"settingsSectionThumbnails": "Эскизы",
|
||||
"settingsThumbnailShowLocationIcon": "Показать значок местоположения",
|
||||
"settingsThumbnailShowMotionPhotoIcon": "Показать значок живого фото",
|
||||
"settingsThumbnailShowRawIcon": "Показать значок RAW-файла",
|
||||
"settingsThumbnailShowVideoDuration": "Показывать продолжительность видео",
|
||||
|
||||
"settingsCollectionQuickActionsTile": "Быстрые действия",
|
||||
"settingsCollectionQuickActionEditorTitle": "Быстрые действия",
|
||||
"settingsCollectionQuickActionTabBrowsing": "Просмотр",
|
||||
"settingsCollectionQuickActionTabSelecting": "Выбор",
|
||||
"settingsCollectionBrowsingQuickActionEditorBanner": "Коснитесь и удерживайте для перемещения кнопок и выбора действий, отображаемых при просмотре объектов.",
|
||||
"settingsCollectionSelectionQuickActionEditorBanner": "Нажмите и удерживайте, чтобы переместить кнопки и выбрать, какие действия будут отображаться при выборе элементов.",
|
||||
|
||||
"settingsSectionViewer": "Просмотрщик",
|
||||
"settingsViewerShowOverlayOnOpening": "Показывать наложение при открытии",
|
||||
"settingsViewerShowMinimap": "Показать миникарту",
|
||||
"settingsViewerShowInformation": "Показывать информацию",
|
||||
"settingsViewerShowInformationSubtitle": "Показать название, дату, местоположение и т.д.",
|
||||
"settingsViewerShowShootingDetails": "Показать детали съёмки",
|
||||
"settingsViewerEnableOverlayBlurEffect": "Наложение эффекта размытия",
|
||||
"settingsViewerUseCutout": "Использовать область выреза",
|
||||
"settingsImageBackground": "Фон изображения",
|
||||
|
||||
"settingsViewerQuickActionsTile": "Быстрые действия",
|
||||
"settingsViewerQuickActionEditorTitle": "Быстрые действия",
|
||||
"settingsViewerQuickActionEditorBanner": "Нажмите и удерживайте для перемещения кнопок и выбора действий, отображаемых в просмотрщике.",
|
||||
"settingsViewerQuickActionEditorDisplayedButtons": "Отображаемые кнопки",
|
||||
"settingsViewerQuickActionEditorAvailableButtons": "Доступные кнопки",
|
||||
"settingsViewerQuickActionEmpty": "Нет кнопок",
|
||||
|
||||
"settingsVideoPageTitle": "Настройки видео",
|
||||
"settingsSectionVideo": "Видео",
|
||||
"settingsVideoShowVideos": "Показывать видео",
|
||||
"settingsVideoEnableHardwareAcceleration": "Аппаратное ускорение",
|
||||
"settingsVideoEnableAutoPlay": "Автозапуск воспроизведения",
|
||||
"settingsVideoLoopModeTile": "Цикличный режим",
|
||||
"settingsVideoLoopModeTitle": "Цикличный режим",
|
||||
"settingsVideoQuickActionsTile": "Быстрые действия для видео",
|
||||
"settingsVideoQuickActionEditorTitle": "Быстрые действия",
|
||||
|
||||
"settingsSubtitleThemeTile": "Субтитры",
|
||||
"settingsSubtitleThemeTitle": "Субтитры",
|
||||
"settingsSubtitleThemeSample": "Это образец.",
|
||||
"settingsSubtitleThemeTextAlignmentTile": "Выравнивание текста",
|
||||
"settingsSubtitleThemeTextAlignmentTitle": "Выравнивание текста",
|
||||
"settingsSubtitleThemeTextSize": "Размер текста",
|
||||
"settingsSubtitleThemeShowOutline": "Показать контур и тень",
|
||||
"settingsSubtitleThemeTextColor": "Цвет текста",
|
||||
"settingsSubtitleThemeTextOpacity": "Непрозрачность текста",
|
||||
"settingsSubtitleThemeBackgroundColor": "Цвет фона",
|
||||
"settingsSubtitleThemeBackgroundOpacity": "Непрозрачность фона",
|
||||
"settingsSubtitleThemeTextAlignmentLeft": "По левой стороне",
|
||||
"settingsSubtitleThemeTextAlignmentCenter": "По центру",
|
||||
"settingsSubtitleThemeTextAlignmentRight": "По правой стороне",
|
||||
|
||||
"settingsSectionPrivacy": "Конфиденциальность",
|
||||
"settingsAllowInstalledAppAccess": "Разрешить доступ к библиотеке приложения",
|
||||
"settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбома",
|
||||
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
|
||||
"settingsSaveSearchHistory": "Сохранять историю поиска",
|
||||
|
||||
"settingsHiddenFiltersTile": "Скрытые фильтры",
|
||||
"settingsHiddenFiltersTitle": "Скрытые фильтры",
|
||||
"settingsHiddenFiltersBanner": "Фотографии и видео, соответствующие скрытым фильтрам, не появятся в вашей коллекции.",
|
||||
"settingsHiddenFiltersEmpty": "Нет скрытых фильтров",
|
||||
|
||||
"settingsHiddenPathsTile": "Скрытые каталоги",
|
||||
"settingsHiddenPathsTitle": "Скрытые каталоги",
|
||||
"settingsHiddenPathsBanner": "Фотографии и видео в этих каталогах или любых их вложенных каталогах не будут отображаться в вашей коллекции.",
|
||||
"settingsHiddenPathsEmpty": "Нет скрытых каталогов",
|
||||
"addPathTooltip": "Добавить каталог",
|
||||
|
||||
"settingsStorageAccessTile": "Доступ к хранилищу",
|
||||
"settingsStorageAccessTitle": "Доступ к хранилищу",
|
||||
"settingsStorageAccessBanner": "Некоторые каталоги требуют обязательного предоставления доступа для изменения файлов в них. Вы можете просмотреть здесь каталоги, к которым вы ранее предоставили доступ.",
|
||||
"settingsStorageAccessEmpty": "Нет прав доступа",
|
||||
"settingsStorageAccessRevokeTooltip": "Отменить",
|
||||
|
||||
"settingsSectionAccessibility": "Специальные возможности",
|
||||
"settingsRemoveAnimationsTile": "Удалить анимацию",
|
||||
"settingsRemoveAnimationsTitle": "Удалить анимацию",
|
||||
"settingsTimeToTakeActionTile": "Время на выполнение действия",
|
||||
"settingsTimeToTakeActionTitle": "Время на выполнение действия",
|
||||
|
||||
"settingsSectionLanguage": "Язык и форматы",
|
||||
"settingsLanguage": "Язык",
|
||||
"settingsCoordinateFormatTile": "Формат координат",
|
||||
"settingsCoordinateFormatTitle": "Формат координат",
|
||||
"settingsUnitSystemTile": "Единицы измерения",
|
||||
"settingsUnitSystemTitle": "Единицы измерения",
|
||||
|
||||
"statsPageTitle": "Статистика",
|
||||
"statsWithGps": "{count, plural, =1{1 объект с местоположением} few{{count} объекта с местоположением} other{{count} объектов с местоположением}}",
|
||||
"statsTopCountries": "Топ стран",
|
||||
"statsTopPlaces": "Топ локаций",
|
||||
"statsTopTags": "Топ тегов",
|
||||
|
||||
"viewerOpenPanoramaButtonLabel": "ОТКРЫТЬ ПАНОРАМУ",
|
||||
"viewerErrorUnknown": "Упс!",
|
||||
"viewerErrorDoesNotExist": "Файл больше не существует.",
|
||||
|
||||
"viewerInfoPageTitle": "Информация",
|
||||
"viewerInfoBackToViewerTooltip": "Вернуться к просмотрщику",
|
||||
|
||||
"viewerInfoUnknown": "неизвестный",
|
||||
"viewerInfoLabelTitle": "Название",
|
||||
"viewerInfoLabelDate": "Дата",
|
||||
"viewerInfoLabelResolution": "Разрешение",
|
||||
"viewerInfoLabelSize": "Размер",
|
||||
"viewerInfoLabelUri": "Идентификатор",
|
||||
"viewerInfoLabelPath": "Расположение",
|
||||
"viewerInfoLabelDuration": "Продолжительность",
|
||||
"viewerInfoLabelOwner": "Владелец",
|
||||
"viewerInfoLabelCoordinates": "Координаты",
|
||||
"viewerInfoLabelAddress": "Адрес",
|
||||
|
||||
"mapStyleTitle": "Стиль карты",
|
||||
"mapStyleTooltip": "Выберите стиль карты",
|
||||
"mapZoomInTooltip": "Увеличить",
|
||||
"mapZoomOutTooltip": "Уменьшить",
|
||||
"mapPointNorthUpTooltip": "Повернуть на север",
|
||||
"mapAttributionOsmHot": "Данные карты © [OpenStreetMap](https://www.openstreetmap.org/copyright) помощники • Плитки [HOT](https://www.hotosm.org/) • Размещена на [OSM France](https://openstreetmap.fr/)",
|
||||
"mapAttributionStamen": "Данные карты © [OpenStreetMap](https://www.openstreetmap.org/copyright) помощники • Плитки [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"openMapPageTooltip": "Просмотреть на странице карты",
|
||||
"mapEmptyRegion": "Нет изображений в этом регионе",
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "Не удалось извлечь встроенные данные",
|
||||
"viewerInfoOpenLinkText": "Открыть",
|
||||
"viewerInfoViewXmlLinkText": "Просмотр XML",
|
||||
|
||||
"viewerInfoSearchFieldLabel": "Поиск метаданных",
|
||||
"viewerInfoSearchEmpty": "Нет подходящих ключей",
|
||||
"viewerInfoSearchSuggestionDate": "Дата и время",
|
||||
"viewerInfoSearchSuggestionDescription": "Описание",
|
||||
"viewerInfoSearchSuggestionDimensions": "Измерения",
|
||||
"viewerInfoSearchSuggestionResolution": "Разрешение",
|
||||
"viewerInfoSearchSuggestionRights": "Права",
|
||||
|
||||
"panoramaEnableSensorControl": "Включить сенсорное управление",
|
||||
"panoramaDisableSensorControl": "Отключить сенсорное управление",
|
||||
|
||||
"sourceViewerPageTitle": "Источник"
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
import 'dart:isolate';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
void mainCommon(AppFlavor flavor) {
|
||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||
// debugPrintGestureArenaDiagnostics = true;
|
||||
|
||||
|
@ -27,5 +28,5 @@ void main() {
|
|||
reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
|
||||
}).sendPort);
|
||||
|
||||
runApp(const AvesApp());
|
||||
runApp(AvesApp(flavor: flavor));
|
||||
}
|
6
lib/main_izzy.dart
Normal file
6
lib/main_izzy.dart
Normal file
|
@ -0,0 +1,6 @@
|
|||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/main_common.dart';
|
||||
|
||||
void main() {
|
||||
mainCommon(AppFlavor.izzy);
|
||||
}
|
6
lib/main_play.dart
Normal file
6
lib/main_play.dart
Normal file
|
@ -0,0 +1,6 @@
|
|||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/main_common.dart';
|
||||
|
||||
void main() {
|
||||
mainCommon(AppFlavor.play);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum ChipSetAction {
|
||||
// general
|
||||
|
@ -9,20 +9,50 @@ enum ChipSetAction {
|
|||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
// browsing
|
||||
search,
|
||||
createAlbum,
|
||||
// all or filter selection
|
||||
// browsing or selecting
|
||||
map,
|
||||
stats,
|
||||
// single/multiple filter selection
|
||||
// selecting (single/multiple filters)
|
||||
delete,
|
||||
hide,
|
||||
pin,
|
||||
unpin,
|
||||
// single filter selection
|
||||
// selecting (single filter)
|
||||
rename,
|
||||
setCover,
|
||||
}
|
||||
|
||||
class ChipSetActions {
|
||||
static const general = [
|
||||
ChipSetAction.sort,
|
||||
ChipSetAction.group,
|
||||
ChipSetAction.select,
|
||||
ChipSetAction.selectAll,
|
||||
ChipSetAction.selectNone,
|
||||
];
|
||||
|
||||
static const browsing = [
|
||||
ChipSetAction.search,
|
||||
ChipSetAction.createAlbum,
|
||||
ChipSetAction.map,
|
||||
ChipSetAction.stats,
|
||||
];
|
||||
|
||||
static const selection = [
|
||||
ChipSetAction.setCover,
|
||||
ChipSetAction.pin,
|
||||
ChipSetAction.unpin,
|
||||
ChipSetAction.delete,
|
||||
ChipSetAction.rename,
|
||||
ChipSetAction.hide,
|
||||
ChipSetAction.map,
|
||||
ChipSetAction.stats,
|
||||
];
|
||||
}
|
||||
|
||||
extension ExtraChipSetAction on ChipSetAction {
|
||||
String getText(BuildContext context) {
|
||||
switch (this) {
|
||||
|
@ -37,13 +67,17 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return context.l10n.menuActionSelectAll;
|
||||
case ChipSetAction.selectNone:
|
||||
return context.l10n.menuActionSelectNone;
|
||||
// browsing
|
||||
case ChipSetAction.search:
|
||||
return MaterialLocalizations.of(context).searchFieldLabel;
|
||||
case ChipSetAction.createAlbum:
|
||||
return context.l10n.chipActionCreateAlbum;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
case ChipSetAction.stats:
|
||||
return context.l10n.menuActionStats;
|
||||
case ChipSetAction.createAlbum:
|
||||
return context.l10n.chipActionCreateAlbum;
|
||||
// single/multiple filters
|
||||
// selecting (single/multiple filters)
|
||||
case ChipSetAction.delete:
|
||||
return context.l10n.chipActionDelete;
|
||||
case ChipSetAction.hide:
|
||||
|
@ -52,7 +86,7 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return context.l10n.chipActionPin;
|
||||
case ChipSetAction.unpin:
|
||||
return context.l10n.chipActionUnpin;
|
||||
// single filter
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
return context.l10n.chipActionRename;
|
||||
case ChipSetAction.setCover:
|
||||
|
@ -77,13 +111,17 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return AIcons.selected;
|
||||
case ChipSetAction.selectNone:
|
||||
return AIcons.unselected;
|
||||
// browsing
|
||||
case ChipSetAction.search:
|
||||
return AIcons.search;
|
||||
case ChipSetAction.createAlbum:
|
||||
return AIcons.add;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
return AIcons.map;
|
||||
case ChipSetAction.stats:
|
||||
return AIcons.stats;
|
||||
case ChipSetAction.createAlbum:
|
||||
return AIcons.add;
|
||||
// single/multiple filters
|
||||
// selecting (single/multiple filters)
|
||||
case ChipSetAction.delete:
|
||||
return AIcons.delete;
|
||||
case ChipSetAction.hide:
|
||||
|
@ -92,7 +130,7 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return AIcons.pin;
|
||||
case ChipSetAction.unpin:
|
||||
return AIcons.unpin;
|
||||
// single filter
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
return AIcons.rename;
|
||||
case ChipSetAction.setCover:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum EntrySetAction {
|
||||
// general
|
||||
|
@ -9,20 +9,43 @@ enum EntrySetAction {
|
|||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
// all
|
||||
// browsing
|
||||
searchCollection,
|
||||
toggleTitleSearch,
|
||||
addShortcut,
|
||||
// all or entry selection
|
||||
// browsing or selecting
|
||||
map,
|
||||
stats,
|
||||
// entry selection
|
||||
// selecting
|
||||
share,
|
||||
delete,
|
||||
copy,
|
||||
move,
|
||||
rescan,
|
||||
rotateCCW,
|
||||
rotateCW,
|
||||
flip,
|
||||
editDate,
|
||||
removeMetadata,
|
||||
}
|
||||
|
||||
class EntrySetActions {
|
||||
static const general = [
|
||||
EntrySetAction.sort,
|
||||
EntrySetAction.group,
|
||||
EntrySetAction.select,
|
||||
EntrySetAction.selectAll,
|
||||
EntrySetAction.selectNone,
|
||||
];
|
||||
|
||||
static const browsing = [
|
||||
EntrySetAction.searchCollection,
|
||||
EntrySetAction.toggleTitleSearch,
|
||||
EntrySetAction.addShortcut,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
];
|
||||
|
||||
static const selection = [
|
||||
EntrySetAction.share,
|
||||
EntrySetAction.delete,
|
||||
|
@ -31,6 +54,7 @@ class EntrySetActions {
|
|||
EntrySetAction.rescan,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
// editing actions are in their subsection
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -48,15 +72,20 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.menuActionSelectAll;
|
||||
case EntrySetAction.selectNone:
|
||||
return context.l10n.menuActionSelectNone;
|
||||
// all
|
||||
// browsing
|
||||
case EntrySetAction.searchCollection:
|
||||
return MaterialLocalizations.of(context).searchFieldLabel;
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
// different data depending on toggle state
|
||||
return context.l10n.collectionActionShowTitleSearch;
|
||||
case EntrySetAction.addShortcut:
|
||||
return context.l10n.collectionActionAddShortcut;
|
||||
// all or entry selection
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
case EntrySetAction.stats:
|
||||
return context.l10n.menuActionStats;
|
||||
// entry selection
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
return context.l10n.entryActionShare;
|
||||
case EntrySetAction.delete:
|
||||
|
@ -67,6 +96,16 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.collectionActionMove;
|
||||
case EntrySetAction.rescan:
|
||||
return context.l10n.collectionActionRescan;
|
||||
case EntrySetAction.rotateCCW:
|
||||
return context.l10n.entryActionRotateCCW;
|
||||
case EntrySetAction.rotateCW:
|
||||
return context.l10n.entryActionRotateCW;
|
||||
case EntrySetAction.flip:
|
||||
return context.l10n.entryActionFlip;
|
||||
case EntrySetAction.editDate:
|
||||
return context.l10n.entryInfoActionEditDate;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return context.l10n.entryInfoActionRemoveMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,15 +126,20 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.selected;
|
||||
case EntrySetAction.selectNone:
|
||||
return AIcons.unselected;
|
||||
// all
|
||||
// browsing
|
||||
case EntrySetAction.searchCollection:
|
||||
return AIcons.search;
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
// different data depending on toggle state
|
||||
return AIcons.filter;
|
||||
case EntrySetAction.addShortcut:
|
||||
return AIcons.addShortcut;
|
||||
// all or entry selection
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
return AIcons.map;
|
||||
case EntrySetAction.stats:
|
||||
return AIcons.stats;
|
||||
// entry selection
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
return AIcons.share;
|
||||
case EntrySetAction.delete:
|
||||
|
@ -106,6 +150,16 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.move;
|
||||
case EntrySetAction.rescan:
|
||||
return AIcons.refresh;
|
||||
case EntrySetAction.rotateCCW:
|
||||
return AIcons.rotateLeft;
|
||||
case EntrySetAction.rotateCW:
|
||||
return AIcons.rotateRight;
|
||||
case EntrySetAction.flip:
|
||||
return AIcons.flip;
|
||||
case EntrySetAction.editDate:
|
||||
return AIcons.date;
|
||||
case EntrySetAction.removeMetadata:
|
||||
return AIcons.clear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
|||
|
||||
enum VideoAction {
|
||||
captureFrame,
|
||||
playOutside,
|
||||
replay10,
|
||||
skip10,
|
||||
selectStreams,
|
||||
|
@ -21,6 +22,7 @@ class VideoActions {
|
|||
VideoAction.selectStreams,
|
||||
VideoAction.replay10,
|
||||
VideoAction.skip10,
|
||||
VideoAction.playOutside,
|
||||
VideoAction.settings,
|
||||
];
|
||||
}
|
||||
|
@ -30,6 +32,8 @@ extension ExtraVideoAction on VideoAction {
|
|||
switch (this) {
|
||||
case VideoAction.captureFrame:
|
||||
return context.l10n.videoActionCaptureFrame;
|
||||
case VideoAction.playOutside:
|
||||
return context.l10n.entryActionOpen;
|
||||
case VideoAction.replay10:
|
||||
return context.l10n.videoActionReplay10;
|
||||
case VideoAction.skip10:
|
||||
|
@ -54,6 +58,8 @@ extension ExtraVideoAction on VideoAction {
|
|||
switch (this) {
|
||||
case VideoAction.captureFrame:
|
||||
return AIcons.captureFrame;
|
||||
case VideoAction.playOutside:
|
||||
return AIcons.openOutside;
|
||||
case VideoAction.replay10:
|
||||
return AIcons.replay10;
|
||||
case VideoAction.skip10:
|
||||
|
|
|
@ -15,7 +15,7 @@ class Covers with ChangeNotifier {
|
|||
Covers._private();
|
||||
|
||||
Future<void> init() async {
|
||||
_rows = await metadataDb.loadCovers();
|
||||
_rows = await metadataDb.loadAllCovers();
|
||||
}
|
||||
|
||||
int get count => _rows.length;
|
||||
|
|
|
@ -18,6 +18,7 @@ import 'package:aves/services/geocoding_service.dart';
|
|||
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:country_code/country_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -548,14 +549,6 @@ class AvesEntry {
|
|||
}.whereNotNull().where((v) => v.isNotEmpty).join(', ');
|
||||
}
|
||||
|
||||
bool search(String query) => {
|
||||
bestTitle,
|
||||
_catalogMetadata?.xmpSubjects,
|
||||
_addressDetails?.countryName,
|
||||
_addressDetails?.adminArea,
|
||||
_addressDetails?.locality,
|
||||
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||||
|
||||
Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
|
||||
final oldDateModifiedSecs = this.dateModifiedSecs;
|
||||
final oldRotationDegrees = this.rotationDegrees;
|
||||
|
@ -635,6 +628,16 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
Future<bool> editDate(DateModifier modifier) async {
|
||||
if (modifier.action == DateEditAction.extractFromTitle) {
|
||||
final _title = bestTitle;
|
||||
if (_title == null) return false;
|
||||
final date = parseUnknownDateFormat(_title);
|
||||
if (date == null) {
|
||||
await reportService.recordError('failed to parse date from title=$_title', null);
|
||||
return false;
|
||||
}
|
||||
modifier = DateModifier(DateEditAction.set, modifier.fields, dateTime: date);
|
||||
}
|
||||
final newFields = await metadataEditService.editDate(this, modifier);
|
||||
return newFields.isNotEmpty;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ class Favourites with ChangeNotifier {
|
|||
Favourites._private();
|
||||
|
||||
Future<void> init() async {
|
||||
_rows = await metadataDb.loadFavourites();
|
||||
_rows = await metadataDb.loadAllFavourites();
|
||||
}
|
||||
|
||||
int get count => _rows.length;
|
||||
|
|
|
@ -4,8 +4,10 @@ import 'package:aves/model/settings/enums.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
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';
|
||||
|
||||
|
@ -37,8 +39,9 @@ class CoordinateFilter extends CollectionFilter {
|
|||
@override
|
||||
EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng);
|
||||
|
||||
String _formatBounds(CoordinateFormat format) {
|
||||
String _formatBounds(AppLocalizations l10n, CoordinateFormat format) {
|
||||
String s(LatLng latLng) => format.format(
|
||||
l10n,
|
||||
latLng,
|
||||
minuteSecondPadding: minuteSecondPadding,
|
||||
dmsSecondDecimals: 0,
|
||||
|
@ -47,10 +50,10 @@ class CoordinateFilter extends CollectionFilter {
|
|||
}
|
||||
|
||||
@override
|
||||
String get universalLabel => _formatBounds(CoordinateFormat.decimal);
|
||||
String get universalLabel => _formatBounds(lookupAppLocalizations(AppLocalizations.supportedLocales.first), CoordinateFormat.decimal);
|
||||
|
||||
@override
|
||||
String getLabel(BuildContext context) => _formatBounds(context.read<Settings>().coordinateFormat);
|
||||
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);
|
||||
|
|
|
@ -31,29 +31,33 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
|||
];
|
||||
|
||||
static CollectionFilter? fromJson(String jsonString) {
|
||||
final jsonMap = jsonDecode(jsonString);
|
||||
if (jsonMap is Map<String, dynamic>) {
|
||||
final type = jsonMap['type'];
|
||||
switch (type) {
|
||||
case AlbumFilter.type:
|
||||
return AlbumFilter.fromMap(jsonMap);
|
||||
case CoordinateFilter.type:
|
||||
return CoordinateFilter.fromMap(jsonMap);
|
||||
case FavouriteFilter.type:
|
||||
return FavouriteFilter.instance;
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case MimeFilter.type:
|
||||
return MimeFilter.fromMap(jsonMap);
|
||||
case PathFilter.type:
|
||||
return PathFilter.fromMap(jsonMap);
|
||||
case QueryFilter.type:
|
||||
return QueryFilter.fromMap(jsonMap);
|
||||
case TagFilter.type:
|
||||
return TagFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
try {
|
||||
final jsonMap = jsonDecode(jsonString);
|
||||
if (jsonMap is Map<String, dynamic>) {
|
||||
final type = jsonMap['type'];
|
||||
switch (type) {
|
||||
case AlbumFilter.type:
|
||||
return AlbumFilter.fromMap(jsonMap);
|
||||
case CoordinateFilter.type:
|
||||
return CoordinateFilter.fromMap(jsonMap);
|
||||
case FavouriteFilter.type:
|
||||
return FavouriteFilter.instance;
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case MimeFilter.type:
|
||||
return MimeFilter.fromMap(jsonMap);
|
||||
case PathFilter.type:
|
||||
return PathFilter.fromMap(jsonMap);
|
||||
case QueryFilter.type:
|
||||
return QueryFilter.fromMap(jsonMap);
|
||||
case TagFilter.type:
|
||||
return TagFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
}
|
||||
}
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to parse filter from json=$jsonString error=$error\n$stack');
|
||||
}
|
||||
debugPrint('failed to parse filter from json=$jsonString');
|
||||
return null;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
|
@ -11,15 +12,15 @@ class QueryFilter extends CollectionFilter {
|
|||
static final RegExp exactRegex = RegExp('^"(.*)"\$');
|
||||
|
||||
final String query;
|
||||
final bool colorful;
|
||||
final bool colorful, live;
|
||||
late final EntryFilter _test;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query];
|
||||
List<Object?> get props => [query, live];
|
||||
|
||||
QueryFilter(this.query, {this.colorful = true}) {
|
||||
QueryFilter(this.query, {this.colorful = true, this.live = false}) {
|
||||
var upQuery = query.toUpperCase();
|
||||
if (upQuery.startsWith('ID=')) {
|
||||
if (upQuery.startsWith('ID:')) {
|
||||
final id = int.tryParse(upQuery.substring(3));
|
||||
_test = (entry) => entry.contentId == id;
|
||||
return;
|
||||
|
@ -37,7 +38,9 @@ class QueryFilter extends CollectionFilter {
|
|||
upQuery = matches.first.group(1)!;
|
||||
}
|
||||
|
||||
_test = not ? (entry) => !entry.search(upQuery) : (entry) => entry.search(upQuery);
|
||||
// default to title search
|
||||
bool testTitle(AvesEntry entry) => entry.bestTitle?.toUpperCase().contains(upQuery) == true;
|
||||
_test = not ? (entry) => !testTitle(entry) : testTitle;
|
||||
}
|
||||
|
||||
QueryFilter.fromMap(Map<String, dynamic> json)
|
||||
|
|
|
@ -8,6 +8,7 @@ enum MetadataField {
|
|||
enum DateEditAction {
|
||||
set,
|
||||
shift,
|
||||
extractFromTitle,
|
||||
clear,
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -25,7 +26,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearEntries();
|
||||
|
||||
Future<Set<AvesEntry>> loadEntries();
|
||||
Future<Set<AvesEntry>> loadAllEntries();
|
||||
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||
|
||||
|
@ -43,7 +44,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearMetadataEntries();
|
||||
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries();
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries();
|
||||
|
||||
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
|
@ -53,7 +54,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearAddresses();
|
||||
|
||||
Future<List<AddressDetails>> loadAddresses();
|
||||
Future<List<AddressDetails>> loadAllAddresses();
|
||||
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
|
@ -63,7 +64,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearFavourites();
|
||||
|
||||
Future<Set<FavouriteRow>> loadFavourites();
|
||||
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||
|
||||
|
@ -75,13 +76,27 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> clearCovers();
|
||||
|
||||
Future<Set<CoverRow>> loadCovers();
|
||||
Future<Set<CoverRow>> loadAllCovers();
|
||||
|
||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||
|
||||
Future<void> updateCoverEntryId(int oldId, CoverRow row);
|
||||
|
||||
Future<void> removeCovers(Set<CollectionFilter> filters);
|
||||
|
||||
// video playback
|
||||
|
||||
Future<void> clearVideoPlayback();
|
||||
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
|
||||
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId);
|
||||
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
|
||||
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId);
|
||||
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds);
|
||||
}
|
||||
|
||||
class SqfliteMetadataDb implements MetadataDb {
|
||||
|
@ -95,6 +110,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
static const addressTable = 'address';
|
||||
static const favouriteTable = 'favourites';
|
||||
static const coverTable = 'covers';
|
||||
static const videoPlaybackTable = 'videoPlayback';
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
|
@ -146,9 +162,13 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
'filter TEXT PRIMARY KEY'
|
||||
', contentId INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
},
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 4,
|
||||
version: 5,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -183,6 +203,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
if (!metadataOnly) {
|
||||
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(coverTable, where: where, whereArgs: whereArgs);
|
||||
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
|
||||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
|
@ -194,11 +215,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(entryTable, where: '1');
|
||||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||
debugPrint('$runtimeType clearEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
Future<Set<AvesEntry>> loadAllEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||
|
@ -252,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearDates() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(dateTakenTable, where: '1');
|
||||
debugPrint('$runtimeType clearDates deleted $count entries');
|
||||
debugPrint('$runtimeType clearDates deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -269,11 +290,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(metadataTable, where: '1');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
|
||||
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CatalogMetadata>> loadMetadataEntries() async {
|
||||
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(metadataTable);
|
||||
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
|
||||
|
@ -330,11 +351,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearAddresses() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(addressTable, where: '1');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count entries');
|
||||
debugPrint('$runtimeType clearAddresses deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AddressDetails>> loadAddresses() async {
|
||||
Future<List<AddressDetails>> loadAllAddresses() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(addressTable);
|
||||
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
|
||||
|
@ -376,11 +397,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearFavourites() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(favouriteTable, where: '1');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count entries');
|
||||
debugPrint('$runtimeType clearFavourites deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<FavouriteRow>> loadFavourites() async {
|
||||
Future<Set<FavouriteRow>> loadAllFavourites() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(favouriteTable);
|
||||
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
|
||||
|
@ -432,11 +453,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> clearCovers() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(coverTable, where: '1');
|
||||
debugPrint('$runtimeType clearCovers deleted $count entries');
|
||||
debugPrint('$runtimeType clearCovers deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<CoverRow>> loadCovers() async {
|
||||
Future<Set<CoverRow>> loadAllCovers() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(coverTable);
|
||||
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
|
||||
|
@ -446,6 +467,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.forEach((row) => _batchInsertCover(batch, row));
|
||||
|
@ -479,4 +501,71 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// video playback
|
||||
|
||||
@override
|
||||
Future<void> clearVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final count = await db.delete(videoPlaybackTable, where: '1');
|
||||
debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable);
|
||||
final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async {
|
||||
if (contentId == null) return null;
|
||||
|
||||
final db = await _database;
|
||||
final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]);
|
||||
if (maps.isEmpty) return null;
|
||||
|
||||
return VideoPlaybackRow.fromMap(maps.first);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
final batch = db.batch();
|
||||
rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertVideoPlayback(Batch batch, VideoPlaybackRow row) {
|
||||
batch.insert(
|
||||
videoPlaybackTable,
|
||||
row.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVideoPlaybackId(int oldId, int? newId) async {
|
||||
if (newId != null) {
|
||||
final db = await _database;
|
||||
await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]);
|
||||
} else {
|
||||
await removeVideoPlayback({oldId});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeVideoPlayback(Set<int> contentIds) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ class MetadataDbUpgrader {
|
|||
static const entryTable = SqfliteMetadataDb.entryTable;
|
||||
static const metadataTable = SqfliteMetadataDb.metadataTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||
|
||||
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
|
||||
// on SQLite <3.25.0, bundled on older Android devices
|
||||
|
@ -21,6 +22,9 @@ class MetadataDbUpgrader {
|
|||
case 3:
|
||||
await _upgradeFrom3(db);
|
||||
break;
|
||||
case 4:
|
||||
await _upgradeFrom4(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
|
@ -109,4 +113,12 @@ class MetadataDbUpgrader {
|
|||
', contentId INTEGER'
|
||||
')');
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom4(Database db) async {
|
||||
debugPrint('upgrading DB from v4');
|
||||
await db.execute('CREATE TABLE $videoPlaybackTable('
|
||||
'contentId INTEGER PRIMARY KEY'
|
||||
', resumeTimeMillis INTEGER'
|
||||
')');
|
||||
}
|
||||
}
|
||||
|
|
31
lib/model/query.dart
Normal file
31
lib/model/query.dart
Normal file
|
@ -0,0 +1,31 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Query extends ChangeNotifier {
|
||||
bool _enabled = false;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
|
||||
set enabled(bool value) {
|
||||
_enabled = value;
|
||||
_enabledStreamController.add(_enabled);
|
||||
queryNotifier.value = '';
|
||||
notifyListeners();
|
||||
|
||||
if (_enabled) {
|
||||
focusRequestNotifier.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void toggle() => enabled = !enabled;
|
||||
|
||||
final StreamController<bool> _enabledStreamController = StreamController<bool>.broadcast();
|
||||
|
||||
Stream<bool> get enabledStream => _enabledStreamController.stream;
|
||||
|
||||
final AChangeNotifier focusRequestNotifier = AChangeNotifier();
|
||||
|
||||
final ValueNotifier<String> queryNotifier = ValueNotifier('');
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
@ -15,12 +16,24 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
|||
}
|
||||
}
|
||||
|
||||
String format(LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
|
||||
String format(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
|
||||
switch (this) {
|
||||
case CoordinateFormat.dms:
|
||||
return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
|
||||
return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
|
||||
case CoordinateFormat.decimal:
|
||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
static List<String> toDMS(AppLocalizations l10n, LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
|
||||
final lat = latLng.latitude;
|
||||
final lng = latLng.longitude;
|
||||
final latSexa = GeoUtils.decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals);
|
||||
final lngSexa = GeoUtils.decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals);
|
||||
return [
|
||||
l10n.coordinateDms(latSexa, lat < 0 ? l10n.coordinateDmsSouth : l10n.coordinateDmsNorth),
|
||||
l10n.coordinateDms(lngSexa, lng < 0 ? l10n.coordinateDmsWest : l10n.coordinateDmsEast),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ class SettingsDefaults {
|
|||
// app
|
||||
static const hasAcceptedTerms = false;
|
||||
static const canUseAnalysisService = true;
|
||||
static const isErrorReportingEnabled = false;
|
||||
static const isInstalledAppAccessAllowed = false;
|
||||
static const isErrorReportingAllowed = false;
|
||||
static const mustBackTwiceToExit = true;
|
||||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
static const homePage = HomePageSetting.collection;
|
||||
|
@ -34,6 +35,9 @@ class SettingsDefaults {
|
|||
// collection
|
||||
static const collectionSectionFactor = EntryGroupFactor.month;
|
||||
static const collectionSortFactor = EntrySortFactor.date;
|
||||
static const collectionBrowsingQuickActions = [
|
||||
EntrySetAction.searchCollection,
|
||||
];
|
||||
static const collectionSelectionQuickActions = [
|
||||
EntrySetAction.share,
|
||||
EntrySetAction.delete,
|
||||
|
|
|
@ -41,7 +41,8 @@ class Settings extends ChangeNotifier {
|
|||
// app
|
||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const canUseAnalysisServiceKey = 'can_use_analysis_service';
|
||||
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
|
||||
static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed';
|
||||
static const isErrorReportingAllowedKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
|
@ -57,6 +58,7 @@ class Settings extends ChangeNotifier {
|
|||
// collection
|
||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions';
|
||||
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
|
||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||
static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo';
|
||||
|
@ -173,9 +175,14 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue);
|
||||
|
||||
bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
|
||||
// TODO TLAD use `true` for transition (it's unset in v1.5.4), but replace by `SettingsDefaults.isInstalledAppAccessAllowed` in a later release
|
||||
bool get isInstalledAppAccessAllowed => getBoolOrDefault(isInstalledAppAccessAllowedKey, true);
|
||||
|
||||
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
|
||||
set isInstalledAppAccessAllowed(bool newValue) => setAndNotify(isInstalledAppAccessAllowedKey, newValue);
|
||||
|
||||
bool get isErrorReportingAllowed => getBoolOrDefault(isErrorReportingAllowedKey, SettingsDefaults.isErrorReportingAllowed);
|
||||
|
||||
set isErrorReportingAllowed(bool newValue) => setAndNotify(isErrorReportingAllowedKey, newValue);
|
||||
|
||||
static const localeSeparator = '-';
|
||||
|
||||
|
@ -265,6 +272,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString());
|
||||
|
||||
List<EntrySetAction> get collectionBrowsingQuickActions => getEnumListOrDefault(collectionBrowsingQuickActionsKey, SettingsDefaults.collectionBrowsingQuickActions, EntrySetAction.values);
|
||||
|
||||
set collectionBrowsingQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionBrowsingQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
||||
List<EntrySetAction> get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values);
|
||||
|
||||
set collectionSelectionQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList());
|
||||
|
@ -563,7 +574,8 @@ class Settings extends ChangeNotifier {
|
|||
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||
}
|
||||
break;
|
||||
case isErrorReportingEnabledKey:
|
||||
case isInstalledAppAccessAllowedKey:
|
||||
case isErrorReportingAllowedKey:
|
||||
case mustBackTwiceToExitKey:
|
||||
case showThumbnailLocationKey:
|
||||
case showThumbnailMotionPhotoKey:
|
||||
|
@ -613,6 +625,7 @@ class Settings extends ChangeNotifier {
|
|||
case drawerPageBookmarksKey:
|
||||
case pinnedFiltersKey:
|
||||
case hiddenFiltersKey:
|
||||
case collectionBrowsingQuickActionsKey:
|
||||
case collectionSelectionQuickActionsKey:
|
||||
case viewerQuickActionsKey:
|
||||
case videoQuickActionsKey:
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
|
@ -36,7 +37,7 @@ class CollectionLens with ChangeNotifier {
|
|||
|
||||
CollectionLens({
|
||||
required this.source,
|
||||
Iterable<CollectionFilter?>? filters,
|
||||
Set<CollectionFilter?>? filters,
|
||||
this.id,
|
||||
this.listenToSource = true,
|
||||
this.fixedSelection,
|
||||
|
@ -126,6 +127,14 @@ class CollectionLens with ChangeNotifier {
|
|||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void setLiveQuery(String query) {
|
||||
filters.removeWhere((v) => v is QueryFilter && v.live);
|
||||
if (query.isNotEmpty) {
|
||||
filters.add(QueryFilter(query, live: true));
|
||||
}
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void _onFilterChanged() {
|
||||
_refresh();
|
||||
filterChangeNotifier.notifyListeners();
|
||||
|
|
|
@ -119,6 +119,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
|
||||
await favourites.remove(entries);
|
||||
await covers.removeEntries(entries);
|
||||
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
|
||||
|
||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||
_rawEntries.removeAll(entries);
|
||||
|
@ -157,6 +158,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
|
||||
await favourites.moveEntry(oldContentId, entry);
|
||||
await covers.moveEntry(oldContentId, entry);
|
||||
await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ mixin LocationMixin on SourceBase {
|
|||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
final saved = await metadataDb.loadAddresses();
|
||||
final saved = await metadataDb.loadAllAddresses();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
onAddressMetadataChanged();
|
||||
|
|
|
@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
clearEntries();
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
||||
final oldEntries = await metadataDb.loadEntries();
|
||||
final oldEntries = await metadataDb.loadAllEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||
|
|
|
@ -15,7 +15,7 @@ mixin TagMixin on SourceBase {
|
|||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
final saved = await metadataDb.loadMetadataEntries();
|
||||
final saved = await metadataDb.loadAllMetadataEntries();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
onCatalogMetadataChanged();
|
||||
|
|
|
@ -22,6 +22,7 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
class VideoMetadataFormatter {
|
||||
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
static final _anotherDatePattern = RegExp(r'(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})');
|
||||
static final _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
|
||||
static final _locationPattern = RegExp(r'([+-][.0-9]+)');
|
||||
static final Map<String, String> _codecNames = {
|
||||
|
@ -80,19 +81,16 @@ class VideoMetadataFormatter {
|
|||
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
|
||||
final mediaInfo = await getVideoMetadata(entry);
|
||||
|
||||
int? dateMillis;
|
||||
|
||||
bool isDefined(dynamic value) => value is String && value != '0';
|
||||
|
||||
var dateString = mediaInfo[Keys.date];
|
||||
if (!isDefined(dateString)) {
|
||||
dateString = mediaInfo[Keys.creationTime];
|
||||
}
|
||||
int? dateMillis;
|
||||
if (isDefined(dateString)) {
|
||||
final date = DateTime.tryParse(dateString);
|
||||
if (date != null) {
|
||||
dateMillis = date.millisecondsSinceEpoch;
|
||||
} else {
|
||||
dateMillis = parseVideoDate(dateString);
|
||||
if (dateMillis == null) {
|
||||
await reportService.recordError('getCatalogMetadata failed to parse date=$dateString for mimeType=${entry.mimeType} entry=$entry', null);
|
||||
}
|
||||
}
|
||||
|
@ -106,6 +104,33 @@ class VideoMetadataFormatter {
|
|||
return entry.catalogMetadata;
|
||||
}
|
||||
|
||||
static int? parseVideoDate(String dateString) {
|
||||
final date = DateTime.tryParse(dateString);
|
||||
if (date != null) {
|
||||
return date.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
// `DateTime` does not recognize:
|
||||
// - `UTC 2021-05-30 19:14:21`
|
||||
|
||||
final match = _anotherDatePattern.firstMatch(dateString);
|
||||
if (match != null) {
|
||||
final year = int.tryParse(match.group(1)!);
|
||||
final month = int.tryParse(match.group(2)!);
|
||||
final day = int.tryParse(match.group(3)!);
|
||||
final hour = int.tryParse(match.group(4)!);
|
||||
final minute = int.tryParse(match.group(5)!);
|
||||
final second = int.tryParse(match.group(6)!);
|
||||
|
||||
if (year != null && month != null && day != null && hour != null && minute != null && second != null) {
|
||||
final date = DateTime(year, month, day, hour, minute, second, 0);
|
||||
return date.millisecondsSinceEpoch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// pattern to extract optional language code suffix, e.g. 'location-eng'
|
||||
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$');
|
||||
|
||||
|
|
27
lib/model/video_playback.dart
Normal file
27
lib/model/video_playback.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class VideoPlaybackRow extends Equatable {
|
||||
final int contentId, resumeTimeMillis;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, resumeTimeMillis];
|
||||
|
||||
const VideoPlaybackRow({
|
||||
required this.contentId,
|
||||
required this.resumeTimeMillis,
|
||||
});
|
||||
|
||||
static VideoPlaybackRow? fromMap(Map map) {
|
||||
return VideoPlaybackRow(
|
||||
contentId: map['contentId'],
|
||||
resumeTimeMillis: map['resumeTimeMillis'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'contentId': contentId,
|
||||
'resumeTimeMillis': resumeTimeMillis,
|
||||
};
|
||||
}
|
|
@ -44,7 +44,8 @@ class MimeTypes {
|
|||
static const aviVnd = 'video/vnd.avi';
|
||||
static const mkv = 'video/x-matroska';
|
||||
static const mov = 'video/quicktime';
|
||||
static const mp2t = 'video/mp2t'; // .m2ts
|
||||
static const mp2t = 'video/mp2t'; // .m2ts, .ts
|
||||
static const mp2ts = 'video/mp2ts'; // .ts (prefer `mp2t` when possible)
|
||||
static const mp4 = 'video/mp4';
|
||||
static const ogv = 'video/ogg';
|
||||
static const webm = 'video/webm';
|
||||
|
@ -67,7 +68,7 @@ class MimeTypes {
|
|||
|
||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
||||
|
||||
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm};
|
||||
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp2ts, mp4, ogv, webm};
|
||||
|
||||
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
|
||||
|
||||
|
|
|
@ -7,9 +7,10 @@ import 'package:aves/services/media/media_file_service.dart';
|
|||
import 'package:aves/services/media/media_store_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_edit_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_fetch_service.dart';
|
||||
import 'package:aves/services/report_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/window_service.dart';
|
||||
import 'package:aves_report/aves_report.dart';
|
||||
import 'package:aves_report_platform/aves_report_platform.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
|
@ -42,7 +43,7 @@ void initPlatformServices() {
|
|||
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
||||
getIt.registerLazySingleton<MetadataEditService>(() => PlatformMetadataEditService());
|
||||
getIt.registerLazySingleton<MetadataFetchService>(() => PlatformMetadataFetchService());
|
||||
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
|
||||
getIt.registerLazySingleton<ReportService>(() => PlatformReportService());
|
||||
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
|
||||
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());
|
||||
}
|
||||
|
|
|
@ -47,6 +47,9 @@ Future<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
|
|||
if (args is Map) {
|
||||
final query = args['query'];
|
||||
final locale = args['locale'];
|
||||
final use24hour = args['use24hour'];
|
||||
debugPrint('getSuggestions query=$query, locale=$locale use24hour=$use24hour');
|
||||
|
||||
if (query is String && locale is String) {
|
||||
final entries = await metadataDb.searchEntries(query, limit: 9);
|
||||
suggestions.addAll(entries.map((entry) {
|
||||
|
@ -55,7 +58,7 @@ Future<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
|
|||
'data': entry.uri,
|
||||
'mimeType': entry.mimeType,
|
||||
'title': entry.bestTitle,
|
||||
'subtitle': date != null ? formatDateTime(date, locale) : null,
|
||||
'subtitle': date != null ? formatDateTime(date, locale, use24hour) : null,
|
||||
'iconUri': entry.uri,
|
||||
};
|
||||
}));
|
||||
|
|
|
@ -44,7 +44,9 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -58,7 +60,9 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -74,7 +78,9 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -88,7 +94,9 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -72,7 +72,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -98,7 +100,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
}) as Map;
|
||||
return OverlayMetadata.fromMap(result);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -140,7 +144,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
}) as Map;
|
||||
return PanoramaInfo.fromMap(result);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -173,7 +179,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
'prop': prop,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -5,12 +5,12 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class Durations {
|
||||
// Flutter animations (with margin)
|
||||
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
|
||||
static const popupMenuAnimation = Duration(milliseconds: 300 + 20); // ref `_kMenuDuration` used in `_PopupMenuRoute`
|
||||
// page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation`
|
||||
static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
|
||||
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
|
||||
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
|
||||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
||||
static const pageTransitionAnimation = Duration(milliseconds: 300 + 20); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
|
||||
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 20); // ref `transitionDuration` used in `DialogRoute`
|
||||
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 20); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
|
||||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 20); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
||||
|
||||
// common animations
|
||||
static const sweeperOpacityAnimation = Duration(milliseconds: 150);
|
||||
|
|
|
@ -2,9 +2,9 @@ import 'package:intl/intl.dart';
|
|||
|
||||
String formatDay(DateTime date, String locale) => DateFormat.yMMMd(locale).format(date);
|
||||
|
||||
String formatTime(DateTime date, String locale) => DateFormat.Hm(locale).format(date);
|
||||
String formatTime(DateTime date, String locale, bool use24hour) => (use24hour ? DateFormat.Hm(locale) : DateFormat.jm(locale)).format(date);
|
||||
|
||||
String formatDateTime(DateTime date, String locale) => '${formatDay(date, locale)} • ${formatTime(date, locale)}';
|
||||
String formatDateTime(DateTime date, String locale, bool use24hour) => '${formatDay(date, locale)} • ${formatTime(date, locale, use24hour)}';
|
||||
|
||||
String formatFriendlyDuration(Duration d) {
|
||||
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
|
||||
|
|
|
@ -46,6 +46,8 @@ class AIcons {
|
|||
static const IconData flip = Icons.flip_outlined;
|
||||
static const IconData favourite = Icons.favorite_border;
|
||||
static const IconData favouriteActive = Icons.favorite;
|
||||
static const IconData filter = MdiIcons.filterOutline;
|
||||
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;
|
||||
|
|
|
@ -39,12 +39,19 @@ class AndroidFileUtils {
|
|||
|
||||
Future<void> initAppNames() async {
|
||||
if (_packages.isEmpty) {
|
||||
debugPrint('Access installed app inventory');
|
||||
_packages = await androidAppService.getPackages();
|
||||
_potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
|
||||
areAppNamesReadyNotifier.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetAppNames() async {
|
||||
_packages.clear();
|
||||
_potentialAppDirs.clear();
|
||||
areAppNamesReadyNotifier.value = false;
|
||||
}
|
||||
|
||||
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO'));
|
||||
|
||||
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots');
|
||||
|
@ -181,9 +188,9 @@ class VolumeRelativeDirectory extends Equatable {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'volumePath': volumePath,
|
||||
'relativeDir': relativeDir,
|
||||
};
|
||||
'volumePath': volumePath,
|
||||
'relativeDir': relativeDir,
|
||||
};
|
||||
|
||||
// prefer static method over a null returning factory constructor
|
||||
static VolumeRelativeDirectory? fromPath(String dirPath) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
@ -86,7 +87,7 @@ class Constants {
|
|||
),
|
||||
];
|
||||
|
||||
static const List<Dependency> flutterPlugins = [
|
||||
static const List<Dependency> _flutterPluginsCommon = [
|
||||
Dependency(
|
||||
name: 'Connectivity Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
|
@ -99,11 +100,6 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'FlutterFire (Core, Crashlytics)',
|
||||
license: 'BSD 3-Clause',
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
Dependency(
|
||||
name: 'fijkplayer (Aves fork)',
|
||||
license: 'MIT',
|
||||
|
@ -160,6 +156,19 @@ class Constants {
|
|||
),
|
||||
];
|
||||
|
||||
static const List<Dependency> _flutterPluginsPlayOnly = [
|
||||
Dependency(
|
||||
name: 'FlutterFire (Core, Crashlytics)',
|
||||
license: 'BSD 3-Clause',
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
];
|
||||
|
||||
static List<Dependency> flutterPlugins(AppFlavor flavor) => [
|
||||
..._flutterPluginsCommon,
|
||||
if (flavor == AppFlavor.play) ..._flutterPluginsPlayOnly,
|
||||
];
|
||||
|
||||
static const List<Dependency> flutterPackages = [
|
||||
Dependency(
|
||||
name: 'Charts',
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:intl/intl.dart';
|
|||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class GeoUtils {
|
||||
static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
|
||||
static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
|
||||
List<int> _split(final double value) {
|
||||
// NumberFormat is necessary to create digit after comma if the value
|
||||
// has no decimal point (only necessary for browser)
|
||||
|
@ -32,16 +32,6 @@ class GeoUtils {
|
|||
return '$deg° $minText′ $secText″';
|
||||
}
|
||||
|
||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
static List<String> toDMS(LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
|
||||
final lat = latLng.latitude;
|
||||
final lng = latLng.longitude;
|
||||
return [
|
||||
'${_decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals)} ${lat < 0 ? 'S' : 'N'}',
|
||||
'${_decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals)} ${lng < 0 ? 'W' : 'E'}',
|
||||
];
|
||||
}
|
||||
|
||||
static LatLng getLatLngCenter(List<LatLng> points) {
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
|
|
|
@ -13,3 +13,58 @@ extension ExtraDateTime on DateTime {
|
|||
|
||||
bool get isThisYear => isAtSameYearAs(DateTime.now());
|
||||
}
|
||||
|
||||
final _unixStampMillisPattern = RegExp(r'\d{13}');
|
||||
final _unixStampSecPattern = RegExp(r'\d{10}');
|
||||
final _plainPattern = RegExp(r'(\d{8})([_-\s](\d{6})([_-\s](\d{3}))?)?');
|
||||
|
||||
DateTime? parseUnknownDateFormat(String s) {
|
||||
var match = _unixStampMillisPattern.firstMatch(s);
|
||||
if (match != null) {
|
||||
final stampString = match.group(0);
|
||||
if (stampString != null) {
|
||||
final stampMillis = int.tryParse(stampString);
|
||||
if (stampMillis != null) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(stampMillis, isUtc: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match = _unixStampSecPattern.firstMatch(s);
|
||||
if (match != null) {
|
||||
final stampString = match.group(0);
|
||||
if (stampString != null) {
|
||||
final stampMillis = int.tryParse(stampString);
|
||||
if (stampMillis != null) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(stampMillis * 1000, isUtc: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match = _plainPattern.firstMatch(s);
|
||||
if (match != null) {
|
||||
final dateString = match.group(1);
|
||||
final timeString = match.group(3);
|
||||
final millisString = match.group(5);
|
||||
|
||||
if (dateString != null) {
|
||||
final year = int.tryParse(dateString.substring(0, 4));
|
||||
final month = int.tryParse(dateString.substring(4, 6));
|
||||
final day = int.tryParse(dateString.substring(6, 8));
|
||||
|
||||
if (year != null && month != null && day != null) {
|
||||
var hour = 0, minute = 0, second = 0, millis = 0;
|
||||
if (timeString != null) {
|
||||
hour = int.tryParse(timeString.substring(0, 2)) ?? 0;
|
||||
minute = int.tryParse(timeString.substring(2, 4)) ?? 0;
|
||||
second = int.tryParse(timeString.substring(4, 6)) ?? 0;
|
||||
|
||||
if (millisString != null) {
|
||||
millis = int.tryParse(millisString) ?? 0;
|
||||
}
|
||||
}
|
||||
return DateTime(year, month, day, hour, minute, second, millis);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:ui';
|
|||
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/about/policy_page.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_logo.dart';
|
||||
|
@ -66,16 +67,18 @@ class _AppReferenceState extends State<AppReference> {
|
|||
}
|
||||
|
||||
Widget _buildLinks() {
|
||||
final l10n = context.l10n;
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.github,
|
||||
size: 24,
|
||||
),
|
||||
text: context.l10n.aboutLinkSources,
|
||||
text: l10n.aboutLinkSources,
|
||||
url: Constants.avesGithub,
|
||||
),
|
||||
LinkChip(
|
||||
|
@ -83,10 +86,28 @@ class _AppReferenceState extends State<AppReference> {
|
|||
AIcons.legal,
|
||||
size: 22,
|
||||
),
|
||||
text: context.l10n.aboutLinkLicense,
|
||||
text: l10n.aboutLinkLicense,
|
||||
url: '${Constants.avesGithub}/blob/main/LICENSE',
|
||||
),
|
||||
LinkChip(
|
||||
leading: const Icon(
|
||||
AIcons.privacy,
|
||||
size: 22,
|
||||
),
|
||||
text: l10n.aboutLinkPolicy,
|
||||
onTap: _goToPolicyPage,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _goToPolicyPage() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: PolicyPage.routeName),
|
||||
builder: (context) => const PolicyPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/flutter_version.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -33,7 +34,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_infoLoader = _getInfo();
|
||||
_infoLoader = _getInfo(context);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -123,14 +124,16 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
);
|
||||
}
|
||||
|
||||
Future<String> _getInfo() async {
|
||||
Future<String> _getInfo(BuildContext context) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final flavor = context.read<AppFlavor>().toString().split('.')[1];
|
||||
return [
|
||||
'Aves version: ${packageInfo.version} (Build ${packageInfo.buildNumber})',
|
||||
'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})',
|
||||
'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})',
|
||||
'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})',
|
||||
'Android build: ${androidInfo.display}',
|
||||
'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
|
||||
'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}',
|
||||
].join('\n');
|
||||
|
|
|
@ -8,6 +8,10 @@ import 'package:flutter/material.dart';
|
|||
class AboutCredits extends StatelessWidget {
|
||||
const AboutCredits({Key? key}) : super(key: key);
|
||||
|
||||
static const translations = [
|
||||
'Русский: D3ZOXY',
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
|
@ -39,6 +43,14 @@ class AboutCredits extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(context.l10n.aboutCreditsTranslators),
|
||||
...translations.map(
|
||||
(line) => Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8, top: 8),
|
||||
child: Text(line),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
|
@ -6,6 +7,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
|||
import 'package:aves/widgets/common/identity/buttons.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Licenses extends StatefulWidget {
|
||||
const Licenses({Key? key}) : super(key: key);
|
||||
|
@ -22,7 +24,7 @@ class _LicensesState extends State<Licenses> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_platform = List<Dependency>.from(Constants.androidDependencies);
|
||||
_flutterPlugins = List<Dependency>.from(Constants.flutterPlugins);
|
||||
_flutterPlugins = List<Dependency>.from(Constants.flutterPlugins(context.read<AppFlavor>()));
|
||||
_flutterPackages = List<Dependency>.from(Constants.flutterPackages);
|
||||
_dartPackages = List<Dependency>.from(Constants.dartPackages);
|
||||
_sortPackages();
|
||||
|
|
49
lib/widgets/about/policy_page.dart
Normal file
49
lib/widgets/about/policy_page.dart
Normal file
|
@ -0,0 +1,49 @@
|
|||
import 'package:aves/widgets/common/basic/markdown_container.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class PolicyPage extends StatefulWidget {
|
||||
static const routeName = '/about/policy';
|
||||
|
||||
const PolicyPage({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_PolicyPageState createState() => _PolicyPageState();
|
||||
}
|
||||
|
||||
class _PolicyPageState extends State<PolicyPage> {
|
||||
late Future<String> _termsLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_termsLoader = rootBundle.loadString('assets/terms.md');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.policyPageTitle),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: FutureBuilder<String>(
|
||||
future: _termsLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
final terms = snapshot.data!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: MarkdownContainer(data: terms),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/settings/accessibility_animations.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
|
@ -11,6 +12,7 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
|
@ -29,7 +31,12 @@ import 'package:provider/provider.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class AvesApp extends StatefulWidget {
|
||||
const AvesApp({Key? key}) : super(key: key);
|
||||
final AppFlavor flavor;
|
||||
|
||||
const AvesApp({
|
||||
Key? key,
|
||||
required this.flavor,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_AvesAppState createState() => _AvesAppState();
|
||||
|
@ -68,59 +75,62 @@ class _AvesAppState extends State<AvesApp> {
|
|||
Widget build(BuildContext context) {
|
||||
// place the settings provider above `MaterialApp`
|
||||
// so it can be used during navigation transitions
|
||||
return ChangeNotifierProvider<Settings>.value(
|
||||
value: settings,
|
||||
child: ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: appModeNotifier,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: DurationsProvider(
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
||||
final home = initialized
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
||||
return Provider<AppFlavor>.value(
|
||||
value: widget.flavor,
|
||||
child: ChangeNotifierProvider<Settings>.value(
|
||||
value: settings,
|
||||
child: ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
value: appModeNotifier,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: DurationsProvider(
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
|
||||
final home = initialized
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
||||
);
|
||||
return Selector<Settings, Tuple2<Locale?, bool>>(
|
||||
selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true),
|
||||
builder: (context, s, child) {
|
||||
final settingsLocale = s.item1;
|
||||
final areAnimationsEnabled = s.item2;
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
builder: (context, child) {
|
||||
if (!areAnimationsEnabled) {
|
||||
child = Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// strip page transitions used by `MaterialPageRoute`
|
||||
pageTransitionsTheme: DirectPageTransitionsTheme(),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
}
|
||||
return child!;
|
||||
},
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
locale: settingsLocale,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// checkerboardRasterCacheImages: true,
|
||||
// checkerboardOffscreenLayers: true,
|
||||
);
|
||||
return Selector<Settings, Tuple2<Locale?, bool>>(
|
||||
selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true),
|
||||
builder: (context, s, child) {
|
||||
final settingsLocale = s.item1;
|
||||
final areAnimationsEnabled = s.item2;
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
builder: (context, child) {
|
||||
if (!areAnimationsEnabled) {
|
||||
child = Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// strip page transitions used by `MaterialPageRoute`
|
||||
pageTransitionsTheme: DirectPageTransitionsTheme(),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
}
|
||||
return child!;
|
||||
},
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
locale: settingsLocale,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// checkerboardRasterCacheImages: true,
|
||||
// checkerboardOffscreenLayers: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -159,12 +169,23 @@ class _AvesAppState extends State<AvesApp> {
|
|||
);
|
||||
settings.keepScreenOn.apply();
|
||||
|
||||
// installed app access
|
||||
settings.updateStream.where((key) => key == Settings.isInstalledAppAccessAllowedKey).listen(
|
||||
(_) {
|
||||
if (settings.isInstalledAppAccessAllowed) {
|
||||
androidFileUtils.initAppNames();
|
||||
} else {
|
||||
androidFileUtils.resetAppNames();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// error reporting
|
||||
await reportService.init();
|
||||
settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen(
|
||||
(_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled),
|
||||
settings.updateStream.where((key) => key == Settings.isErrorReportingAllowedKey).listen(
|
||||
(_) => reportService.setCollectionEnabled(settings.isErrorReportingAllowed),
|
||||
);
|
||||
await reportService.setCollectionEnabled(settings.isErrorReportingEnabled);
|
||||
await reportService.setCollectionEnabled(settings.isErrorReportingAllowed);
|
||||
|
||||
FlutterError.onError = reportService.recordFlutterError;
|
||||
final now = DateTime.now();
|
||||
|
|
|
@ -3,7 +3,8 @@ import 'dart:async';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/query.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
|
@ -11,15 +12,15 @@ import 'package:aves/model/source/collection_source.dart';
|
|||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||||
import 'package:aves/widgets/collection/query_bar.dart';
|
||||
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/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -42,20 +43,27 @@ class CollectionAppBar extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
|
||||
late AnimationController _browseToSelectAnimation;
|
||||
late Future<bool> _canAddShortcutsLoader;
|
||||
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
|
||||
final FocusNode _queryBarFocusNode = FocusNode();
|
||||
late final Listenable _queryFocusRequestNotifier;
|
||||
|
||||
CollectionLens get collection => widget.collection;
|
||||
|
||||
CollectionSource get source => collection.source;
|
||||
|
||||
bool get hasFilters => collection.filters.isNotEmpty;
|
||||
bool get showFilterBar => collection.filters.any((v) => !(v is QueryFilter && v.live));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final query = context.read<Query>();
|
||||
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
|
||||
_queryFocusRequestNotifier = query.focusRequestNotifier;
|
||||
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
|
||||
_browseToSelectAnimation = AnimationController(
|
||||
duration: context.read<DurationsData>().iconAnimation,
|
||||
vsync: this,
|
||||
|
@ -76,8 +84,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
_queryFocusRequestNotifier.removeListener(_onQueryFocusRequest);
|
||||
_isSelectingNotifier.removeListener(_onActivityChange);
|
||||
_browseToSelectAnimation.dispose();
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -92,27 +104,55 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
return Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => selection.isSelecting,
|
||||
builder: (context, isSelecting, child) {
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(isSelecting),
|
||||
bottom: hasFilters
|
||||
? FilterBar(
|
||||
filters: collection.filters,
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
)
|
||||
: null,
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
return FutureBuilder<bool>(
|
||||
future: _canAddShortcutsLoader,
|
||||
builder: (context, snapshot) {
|
||||
final canAddShortcuts = snapshot.data ?? false;
|
||||
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
|
||||
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
|
||||
builder: (context, s, child) {
|
||||
final isSelecting = s.item1;
|
||||
final selectedItemCount = s.item2;
|
||||
_isSelectingNotifier.value = isSelecting;
|
||||
return AnimatedBuilder(
|
||||
animation: collection.filterChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final removableFilters = appMode != AppMode.pickInternal;
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return SliverAppBar(
|
||||
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
|
||||
title: _buildAppBarTitle(isSelecting),
|
||||
actions: _buildActions(
|
||||
isSelecting: isSelecting,
|
||||
selectedItemCount: selectedItemCount,
|
||||
supportShortcuts: canAddShortcuts,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appBarBottomHeight),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showFilterBar)
|
||||
FilterBar(
|
||||
filters: collection.filters.where((v) => !(v is QueryFilter && v.live)).toSet(),
|
||||
removable: removableFilters,
|
||||
onTap: removableFilters ? collection.removeFilter : null,
|
||||
),
|
||||
if (queryEnabled)
|
||||
EntryQueryBar(
|
||||
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
|
||||
focusNode: _queryBarFocusNode,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -120,6 +160,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
|
||||
double get appBarBottomHeight {
|
||||
final hasQuery = context.read<Query>().enabled;
|
||||
return (showFilterBar ? FilterBar.preferredHeight : .0) + (hasQuery ? EntryQueryBar.preferredHeight : .0);
|
||||
}
|
||||
|
||||
Widget _buildAppBarLeading(bool isSelecting) {
|
||||
VoidCallback? onPressed;
|
||||
String? tooltip;
|
||||
|
@ -143,14 +188,16 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
Widget? _buildAppBarTitle(bool isSelecting) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
if (isSelecting) {
|
||||
return Selector<Selection<AvesEntry>, int>(
|
||||
selector: (context, selection) => selection.selectedItems.length,
|
||||
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
|
||||
builder: (context, count, child) => Text(l10n.collectionSelectionPageTitle(count)),
|
||||
);
|
||||
} else {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle);
|
||||
Widget title = Text(appMode.isPicking ? l10n.collectionPickPageTitle : l10n.collectionPageTitle);
|
||||
if (appMode == AppMode.main) {
|
||||
title = SourceStateAwareAppBarTitle(
|
||||
title: title,
|
||||
|
@ -164,94 +211,171 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(bool isSelecting) {
|
||||
List<Widget> _buildActions({
|
||||
required bool isSelecting,
|
||||
required int selectedItemCount,
|
||||
required bool supportShortcuts,
|
||||
}) {
|
||||
final appMode = context.watch<ValueNotifier<AppMode>>().value;
|
||||
final selectionQuickActions = settings.collectionSelectionQuickActions;
|
||||
return [
|
||||
if (!isSelecting && appMode.canSearch)
|
||||
CollectionSearchButton(
|
||||
source: source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
if (isSelecting)
|
||||
...selectionQuickActions.map((action) => Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => selection.selectedItems.isEmpty,
|
||||
builder: (context, isEmpty, child) => IconButton(
|
||||
icon: action.getIcon(),
|
||||
onPressed: isEmpty ? null : () => _onCollectionActionSelected(action),
|
||||
tooltip: action.getText(context),
|
||||
),
|
||||
)),
|
||||
FutureBuilder<bool>(
|
||||
future: _canAddShortcutsLoader,
|
||||
builder: (context, snapshot) {
|
||||
final canAddShortcuts = snapshot.data ?? false;
|
||||
return MenuIconTheme(
|
||||
child: PopupMenuButton<EntrySetAction>(
|
||||
// key is expected by test driver
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final groupable = collection.sortFactor == EntrySortFactor.date;
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final isSelecting = selection.isSelecting;
|
||||
final selectedItems = selection.selectedItems;
|
||||
final hasSelection = selectedItems.isNotEmpty;
|
||||
final hasItems = !collection.isEmpty;
|
||||
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
|
||||
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
|
||||
action,
|
||||
appMode: appMode,
|
||||
isSelecting: isSelecting,
|
||||
supportShortcuts: supportShortcuts,
|
||||
sortFactor: collection.sortFactor,
|
||||
itemCount: collection.entryCount,
|
||||
selectedItemCount: selectedItemCount,
|
||||
);
|
||||
bool canApply(EntrySetAction action) => _actionDelegate.canApply(
|
||||
action,
|
||||
isSelecting: isSelecting,
|
||||
itemCount: collection.entryCount,
|
||||
selectedItemCount: selectedItemCount,
|
||||
);
|
||||
final canApplyEditActions = selectedItemCount > 0;
|
||||
|
||||
return [
|
||||
_toMenuItem(EntrySetAction.sort),
|
||||
if (groupable) _toMenuItem(EntrySetAction.group),
|
||||
if (appMode == AppMode.main) ...[
|
||||
if (!isSelecting)
|
||||
_toMenuItem(
|
||||
EntrySetAction.select,
|
||||
enabled: hasItems,
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)),
|
||||
if (!isSelecting)
|
||||
final browsingQuickActions = settings.collectionBrowsingQuickActions;
|
||||
final selectionQuickActions = settings.collectionSelectionQuickActions;
|
||||
final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
|
||||
(action) => _toActionButton(action, enabled: canApply(action)),
|
||||
);
|
||||
|
||||
return [
|
||||
...quickActionButtons,
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<EntrySetAction>(
|
||||
// key is expected by test driver
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final generalMenuItems = EntrySetActions.general.where(isVisible).map(
|
||||
(action) => _toMenuItem(action, enabled: canApply(action)),
|
||||
);
|
||||
|
||||
final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v));
|
||||
final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v));
|
||||
final contextualMenuItems = [
|
||||
...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
|
||||
(action) => _toMenuItem(action, enabled: canApply(action)),
|
||||
),
|
||||
if (isSelecting)
|
||||
PopupMenuItem<EntrySetAction>(
|
||||
enabled: canApplyEditActions,
|
||||
padding: EdgeInsets.zero,
|
||||
child: PopupMenuItemExpansionPanel<EntrySetAction>(
|
||||
enabled: canApplyEditActions,
|
||||
icon: AIcons.edit,
|
||||
title: context.l10n.collectionActionEdit,
|
||||
items: [
|
||||
_buildRotateAndFlipMenuItems(context, canApply: canApply),
|
||||
...[
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
|
||||
if (!isSelecting && canAddShortcuts) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(EntrySetAction.addShortcut),
|
||||
EntrySetAction.editDate,
|
||||
EntrySetAction.removeMetadata,
|
||||
].map((action) => _toMenuItem(action, enabled: canApply(action))),
|
||||
],
|
||||
],
|
||||
if (isSelecting) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(
|
||||
EntrySetAction.selectAll,
|
||||
enabled: selectedItems.length < collection.entryCount,
|
||||
),
|
||||
_toMenuItem(
|
||||
EntrySetAction.selectNone,
|
||||
enabled: hasSelection,
|
||||
),
|
||||
]
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
await _onCollectionActionSelected(action);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return [
|
||||
...generalMenuItems,
|
||||
if (contextualMenuItems.isNotEmpty) ...[
|
||||
const PopupMenuDivider(),
|
||||
...contextualMenuItems,
|
||||
],
|
||||
];
|
||||
},
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
await _onActionSelected(action);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {bool enabled = true}) {
|
||||
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
|
||||
Key _getActionKey(EntrySetAction action) => Key('menu-${action.toString().substring('EntrySetAction.'.length)}');
|
||||
|
||||
Widget _toActionButton(EntrySetAction action, {required bool enabled}) {
|
||||
final onPressed = enabled ? () => _onActionSelected(action) : null;
|
||||
switch (action) {
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
return Selector<Query, bool>(
|
||||
selector: (context, query) => query.enabled,
|
||||
builder: (context, queryEnabled, child) {
|
||||
return _TitleSearchToggler(
|
||||
queryEnabled: queryEnabled,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
},
|
||||
);
|
||||
default:
|
||||
return IconButton(
|
||||
key: _getActionKey(action),
|
||||
icon: action.getIcon(),
|
||||
onPressed: onPressed,
|
||||
tooltip: action.getText(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {required bool enabled}) {
|
||||
late Widget child;
|
||||
switch (action) {
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
child = _TitleSearchToggler(
|
||||
queryEnabled: context.read<Query>().enabled,
|
||||
isMenuItem: true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
child = MenuRow(text: action.getText(context), icon: action.getIcon());
|
||||
break;
|
||||
}
|
||||
return PopupMenuItem(
|
||||
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
|
||||
key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'),
|
||||
key: _getActionKey(action),
|
||||
value: action,
|
||||
enabled: enabled,
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<EntrySetAction> _buildRotateAndFlipMenuItems(
|
||||
BuildContext context, {
|
||||
required bool Function(EntrySetAction action) canApply,
|
||||
}) {
|
||||
Widget buildDivider() => const SizedBox(
|
||||
height: 16,
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildItem(EntrySetAction action) => Expanded(
|
||||
child: PopupMenuItem(
|
||||
value: action,
|
||||
enabled: canApply(action),
|
||||
child: Tooltip(
|
||||
message: action.getText(context),
|
||||
child: Center(child: action.getIcon()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
buildDivider(),
|
||||
buildItem(EntrySetAction.rotateCCW),
|
||||
buildDivider(),
|
||||
buildItem(EntrySetAction.rotateCW),
|
||||
buildDivider(),
|
||||
buildItem(EntrySetAction.flip),
|
||||
buildDivider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -264,10 +388,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
void _onFilterChanged() {
|
||||
widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0);
|
||||
_updateAppBarHeight();
|
||||
|
||||
if (hasFilters) {
|
||||
final filters = collection.filters;
|
||||
final filters = collection.filters;
|
||||
if (filters.isNotEmpty) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet();
|
||||
|
@ -276,16 +400,18 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
|
||||
void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus();
|
||||
|
||||
void _updateAppBarHeight() => widget.appBarHeightNotifier.value = kToolbarHeight + appBarBottomHeight;
|
||||
|
||||
Future<void> _onActionSelected(EntrySetAction action) async {
|
||||
switch (action) {
|
||||
case EntrySetAction.share:
|
||||
case EntrySetAction.delete:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rescan:
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
_actionDelegate.onActionSelected(context, action);
|
||||
// general
|
||||
case EntrySetAction.sort:
|
||||
await _sort();
|
||||
break;
|
||||
case EntrySetAction.group:
|
||||
await _group();
|
||||
break;
|
||||
case EntrySetAction.select:
|
||||
context.read<Selection<AvesEntry>>().select();
|
||||
|
@ -296,74 +422,71 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case EntrySetAction.selectNone:
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
// browsing
|
||||
case EntrySetAction.searchCollection:
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
case EntrySetAction.addShortcut:
|
||||
unawaited(_showShortcutDialog(context));
|
||||
break;
|
||||
case EntrySetAction.group:
|
||||
final value = await showDialog<EntryGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
|
||||
initialValue: settings.collectionSectionFactor,
|
||||
options: {
|
||||
EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
|
||||
EntryGroupFactor.month: context.l10n.collectionGroupMonth,
|
||||
EntryGroupFactor.day: context.l10n.collectionGroupDay,
|
||||
EntryGroupFactor.none: context.l10n.collectionGroupNone,
|
||||
},
|
||||
title: context.l10n.collectionGroupTitle,
|
||||
),
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
break;
|
||||
case EntrySetAction.sort:
|
||||
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,
|
||||
),
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
case EntrySetAction.delete:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rescan:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.removeMetadata:
|
||||
_actionDelegate.onActionSelected(context, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showShortcutDialog(BuildContext context) async {
|
||||
final filters = collection.filters;
|
||||
String? defaultName;
|
||||
if (filters.isNotEmpty) {
|
||||
// we compute the default name beforehand
|
||||
// because some filter labels need localization
|
||||
final sortedFilters = List<CollectionFilter>.from(filters)..sort();
|
||||
defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
|
||||
}
|
||||
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||
Future<void> _sort() async {
|
||||
final value = await showDialog<EntrySortFactor>(
|
||||
context: context,
|
||||
builder: (context) => AddShortcutDialog(
|
||||
collection: collection,
|
||||
defaultName: defaultName ?? '',
|
||||
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,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
final coverEntry = result.item1;
|
||||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters));
|
||||
Future<void> _group() async {
|
||||
final value = await showDialog<EntryGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return AvesSelectionDialog<EntryGroupFactor>(
|
||||
initialValue: settings.collectionSectionFactor,
|
||||
options: {
|
||||
EntryGroupFactor.album: l10n.collectionGroupAlbum,
|
||||
EntryGroupFactor.month: l10n.collectionGroupMonth,
|
||||
EntryGroupFactor.day: l10n.collectionGroupDay,
|
||||
EntryGroupFactor.none: l10n.collectionGroupNone,
|
||||
},
|
||||
title: l10n.collectionGroupTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
void _goToSearch() {
|
||||
|
@ -378,3 +501,30 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleSearchToggler extends StatelessWidget {
|
||||
final bool queryEnabled, isMenuItem;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _TitleSearchToggler({
|
||||
required this.queryEnabled,
|
||||
this.isMenuItem = false,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(queryEnabled ? AIcons.filterOff : AIcons.filter);
|
||||
final text = queryEnabled ? context.l10n.collectionActionHideTitleSearch : context.l10n.collectionActionShowTitleSearch;
|
||||
return isMenuItem
|
||||
? MenuRow(
|
||||
text: text,
|
||||
icon: icon,
|
||||
)
|
||||
: IconButton(
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
tooltip: text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/widgets/collection/collection_grid.dart';
|
|||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/query_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -39,25 +40,27 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
return MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: Builder(
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
// key is expected by test driver
|
||||
key: Key('collection-grid'),
|
||||
child: QueryProvider(
|
||||
child: Builder(
|
||||
builder: (context) => WillPopScope(
|
||||
onWillPop: () {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return SynchronousFuture(false);
|
||||
}
|
||||
return SynchronousFuture(true);
|
||||
},
|
||||
child: DoubleBackPopScope(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
// key is expected by test driver
|
||||
key: Key('collection-grid'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,59 +1,188 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/query.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
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';
|
||||
|
||||
class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
bool isVisible(
|
||||
EntrySetAction action, {
|
||||
required AppMode appMode,
|
||||
required bool isSelecting,
|
||||
required bool supportShortcuts,
|
||||
required EntrySortFactor sortFactor,
|
||||
required int itemCount,
|
||||
required int selectedItemCount,
|
||||
}) {
|
||||
switch (action) {
|
||||
// general
|
||||
case EntrySetAction.sort:
|
||||
return true;
|
||||
case EntrySetAction.group:
|
||||
return sortFactor == EntrySortFactor.date;
|
||||
case EntrySetAction.select:
|
||||
return appMode.canSelect && !isSelecting;
|
||||
case EntrySetAction.selectAll:
|
||||
return isSelecting && selectedItemCount < itemCount;
|
||||
case EntrySetAction.selectNone:
|
||||
return isSelecting && selectedItemCount == itemCount;
|
||||
// browsing
|
||||
case EntrySetAction.searchCollection:
|
||||
return appMode.canSearch && !isSelecting;
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
return !isSelecting;
|
||||
case EntrySetAction.addShortcut:
|
||||
return appMode == AppMode.main && !isSelecting && supportShortcuts;
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
return appMode == AppMode.main;
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
case EntrySetAction.delete:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rescan:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return appMode == AppMode.main && isSelecting;
|
||||
}
|
||||
}
|
||||
|
||||
bool canApply(
|
||||
EntrySetAction action, {
|
||||
required bool isSelecting,
|
||||
required int itemCount,
|
||||
required int selectedItemCount,
|
||||
}) {
|
||||
final hasItems = itemCount > 0;
|
||||
final hasSelection = selectedItemCount > 0;
|
||||
|
||||
switch (action) {
|
||||
case EntrySetAction.sort:
|
||||
case EntrySetAction.group:
|
||||
return true;
|
||||
case EntrySetAction.select:
|
||||
return hasItems;
|
||||
case EntrySetAction.selectAll:
|
||||
return selectedItemCount < itemCount;
|
||||
case EntrySetAction.selectNone:
|
||||
return hasSelection;
|
||||
case EntrySetAction.searchCollection:
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
case EntrySetAction.addShortcut:
|
||||
return true;
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
return (!isSelecting && hasItems) || (isSelecting && hasSelection);
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
case EntrySetAction.delete:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.rescan:
|
||||
case EntrySetAction.rotateCCW:
|
||||
case EntrySetAction.rotateCW:
|
||||
case EntrySetAction.flip:
|
||||
case EntrySetAction.editDate:
|
||||
case EntrySetAction.removeMetadata:
|
||||
return hasSelection;
|
||||
}
|
||||
}
|
||||
|
||||
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
void onActionSelected(BuildContext context, EntrySetAction action) {
|
||||
switch (action) {
|
||||
case EntrySetAction.share:
|
||||
_share(context);
|
||||
// general
|
||||
case EntrySetAction.sort:
|
||||
case EntrySetAction.group:
|
||||
case EntrySetAction.select:
|
||||
case EntrySetAction.selectAll:
|
||||
case EntrySetAction.selectNone:
|
||||
break;
|
||||
case EntrySetAction.delete:
|
||||
_showDeleteDialog(context);
|
||||
// browsing
|
||||
case EntrySetAction.searchCollection:
|
||||
_goToSearch(context);
|
||||
break;
|
||||
case EntrySetAction.copy:
|
||||
_moveSelection(context, moveType: MoveType.copy);
|
||||
case EntrySetAction.toggleTitleSearch:
|
||||
context.read<Query>().toggle();
|
||||
break;
|
||||
case EntrySetAction.move:
|
||||
_moveSelection(context, moveType: MoveType.move);
|
||||
break;
|
||||
case EntrySetAction.rescan:
|
||||
_rescan(context);
|
||||
case EntrySetAction.addShortcut:
|
||||
_addShortcut(context);
|
||||
break;
|
||||
// browsing or selecting
|
||||
case EntrySetAction.map:
|
||||
_goToMap(context);
|
||||
break;
|
||||
case EntrySetAction.stats:
|
||||
_goToStats(context);
|
||||
break;
|
||||
default:
|
||||
// selecting
|
||||
case EntrySetAction.share:
|
||||
_share(context);
|
||||
break;
|
||||
case EntrySetAction.delete:
|
||||
_delete(context);
|
||||
break;
|
||||
case EntrySetAction.copy:
|
||||
_move(context, moveType: MoveType.copy);
|
||||
break;
|
||||
case EntrySetAction.move:
|
||||
_move(context, moveType: MoveType.move);
|
||||
break;
|
||||
case EntrySetAction.rescan:
|
||||
_rescan(context);
|
||||
break;
|
||||
case EntrySetAction.rotateCCW:
|
||||
_rotate(context, clockwise: false);
|
||||
break;
|
||||
case EntrySetAction.rotateCW:
|
||||
_rotate(context, clockwise: true);
|
||||
break;
|
||||
case EntrySetAction.flip:
|
||||
_flip(context);
|
||||
break;
|
||||
case EntrySetAction.editDate:
|
||||
_editDate(context);
|
||||
break;
|
||||
case EntrySetAction.removeMetadata:
|
||||
_removeMetadata(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -81,7 +210,60 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
selection.browse();
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
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 todoCount = selectedItems.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.deleteButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
|
||||
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.delete(selectedItems),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
await source.removeEntries(deletedUris);
|
||||
selection.browse();
|
||||
source.resumeMonitoring();
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
if (deletedCount < todoCount) {
|
||||
final count = todoCount - deletedCount;
|
||||
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
|
||||
}
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
|
||||
final l10n = context.l10n;
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
|
@ -104,15 +286,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
// do not directly use selection when moving and post-processing items
|
||||
// as source monitoring may remove obsolete items from the original selection
|
||||
final todoEntries = selectedItems.toSet();
|
||||
final todoItems = selectedItems.toSet();
|
||||
|
||||
final copy = moveType == MoveType.copy;
|
||||
final todoCount = todoEntries.length;
|
||||
final todoCount = todoItems.length;
|
||||
assert(todoCount > 0);
|
||||
|
||||
final destinationDirectory = Directory(destinationAlbum);
|
||||
final names = [
|
||||
...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
|
||||
...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
|
||||
// do not guard up front based on directory existence,
|
||||
// as conflicts could be within moved entries scattered across multiple albums
|
||||
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
|
||||
|
@ -139,7 +321,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.move(
|
||||
todoEntries,
|
||||
todoItems,
|
||||
copy: copy,
|
||||
destinationAlbum: destinationAlbum,
|
||||
nameConflictStrategy: nameConflictStrategy,
|
||||
|
@ -149,7 +331,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final successOps = processed.where((e) => e.success).toSet();
|
||||
final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet();
|
||||
await source.updateAfterMove(
|
||||
todoEntries: todoEntries,
|
||||
todoEntries: todoItems,
|
||||
copy: copy,
|
||||
destinationAlbum: destinationAlbum,
|
||||
movedOps: movedOps,
|
||||
|
@ -213,57 +395,128 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||
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 todoCount = selectedItems.length;
|
||||
Future<void> _edit(
|
||||
BuildContext context,
|
||||
Selection<AvesEntry> selection,
|
||||
Set<AvesEntry> todoItems,
|
||||
Future<bool> Function(AvesEntry entry) op,
|
||||
) async {
|
||||
final selectionDirs = todoItems.map((e) => e.directory).whereNotNull().toSet();
|
||||
final todoCount = todoItems.length;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: todoItems)) return;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
|
||||
final success = await op(entry);
|
||||
return ImageOpEvent(success: success, uri: entry.uri);
|
||||
}).asBroadcastStream(),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final successOps = processed.where((e) => e.success).toSet();
|
||||
selection.browse();
|
||||
source.resumeMonitoring();
|
||||
unawaited(source.refreshUris(successOps.map((v) => v.uri).toSet()));
|
||||
|
||||
final l10n = context.l10n;
|
||||
final successCount = successOps.length;
|
||||
if (successCount < todoCount) {
|
||||
final count = todoCount - successCount;
|
||||
showFeedback(context, l10n.collectionEditFailureFeedback(count));
|
||||
} else {
|
||||
final count = successCount;
|
||||
showFeedback(context, l10n.collectionEditSuccessFeedback(count));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<AvesEntry>?> _getEditableItems(
|
||||
BuildContext context, {
|
||||
required Set<AvesEntry> selectedItems,
|
||||
required bool Function(AvesEntry entry) canEdit,
|
||||
}) async {
|
||||
final bySupported = groupBy<AvesEntry, bool>(selectedItems, canEdit);
|
||||
final supported = (bySupported[true] ?? []).toSet();
|
||||
final unsupported = (bySupported[false] ?? []).toSet();
|
||||
|
||||
if (unsupported.isEmpty) return supported;
|
||||
|
||||
final unsupportedTypes = unsupported.map((entry) => entry.mimeType).toSet().map(MimeUtils.displayType).toList()..sort();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)),
|
||||
title: l10n.unsupportedTypeDialogTitle,
|
||||
content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.deleteButtonLabel),
|
||||
),
|
||||
if (supported.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(l10n.continueButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
if (confirmed == null || !confirmed) return null;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
|
||||
return supported;
|
||||
}
|
||||
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.delete(selectedItems),
|
||||
itemCount: todoCount,
|
||||
onDone: (processed) async {
|
||||
final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet();
|
||||
await source.removeEntries(deletedUris);
|
||||
selection.browse();
|
||||
source.resumeMonitoring();
|
||||
Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final deletedCount = deletedUris.length;
|
||||
if (deletedCount < todoCount) {
|
||||
final count = todoCount - deletedCount;
|
||||
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
|
||||
}
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
|
||||
if (todoItems == null || todoItems.isEmpty) return;
|
||||
|
||||
// cleanup
|
||||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
},
|
||||
);
|
||||
await _edit(context, selection, todoItems, (entry) => entry.rotate(clockwise: clockwise, persist: true));
|
||||
}
|
||||
|
||||
Future<void> _flip(BuildContext context) async {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRotateAndFlip);
|
||||
if (todoItems == null || todoItems.isEmpty) return;
|
||||
|
||||
await _edit(context, selection, todoItems, (entry) => entry.flip(persist: true));
|
||||
}
|
||||
|
||||
Future<void> _editDate(BuildContext context) async {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canEditExif);
|
||||
if (todoItems == null || todoItems.isEmpty) return;
|
||||
|
||||
final modifier = await selectDateModifier(context, todoItems);
|
||||
if (modifier == null) return;
|
||||
|
||||
await _edit(context, selection, todoItems, (entry) => entry.editDate(modifier));
|
||||
}
|
||||
|
||||
Future<void> _removeMetadata(BuildContext context) async {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
final todoItems = await _getEditableItems(context, selectedItems: selectedItems, canEdit: (entry) => entry.canRemoveMetadata);
|
||||
if (todoItems == null || todoItems.isEmpty) return;
|
||||
|
||||
final types = await selectMetadataToRemove(context, todoItems);
|
||||
if (types == null || types.isEmpty) return;
|
||||
|
||||
await _edit(context, selection, todoItems, (entry) => entry.removeMetadata(types));
|
||||
}
|
||||
|
||||
void _goToMap(BuildContext context) {
|
||||
|
@ -304,4 +557,45 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
final collection = context.read<CollectionLens>();
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: CollectionSearchDelegate(
|
||||
source: collection.source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addShortcut(BuildContext context) async {
|
||||
final collection = context.read<CollectionLens>();
|
||||
final filters = collection.filters;
|
||||
|
||||
String? defaultName;
|
||||
if (filters.isNotEmpty) {
|
||||
// we compute the default name beforehand
|
||||
// because some filter labels need localization
|
||||
final sortedFilters = List<CollectionFilter>.from(filters)..sort();
|
||||
defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
|
||||
}
|
||||
final result = await showDialog<Tuple2<AvesEntry?, String>>(
|
||||
context: context,
|
||||
builder: (context) => AddShortcutDialog(
|
||||
collection: collection,
|
||||
defaultName: defaultName ?? '',
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
final coverEntry = result.item1;
|
||||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
class FilterBar extends StatefulWidget {
|
||||
static const double verticalPadding = 16;
|
||||
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
|
||||
|
||||
|
@ -19,9 +19,6 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
|||
}) : filters = List<CollectionFilter>.from(filters)..sort(),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
final Size preferredSize = const Size.fromHeight(preferredHeight);
|
||||
|
||||
@override
|
||||
_FilterBarState createState() => _FilterBarState();
|
||||
}
|
||||
|
|
76
lib/widgets/collection/query_bar.dart
Normal file
76
lib/widgets/collection/query_bar.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/common/basic/query_bar.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EntryQueryBar extends StatefulWidget {
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final FocusNode focusNode;
|
||||
|
||||
static const preferredHeight = kToolbarHeight;
|
||||
|
||||
const EntryQueryBar({
|
||||
Key? key,
|
||||
required this.queryNotifier,
|
||||
required this.focusNode,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_EntryQueryBarState createState() => _EntryQueryBarState();
|
||||
}
|
||||
|
||||
class _EntryQueryBarState extends State<EntryQueryBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EntryQueryBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// TODO TLAD focus on text field when enabled (`autofocus` is unusable)
|
||||
// TODO TLAD lose focus on navigation to viewer?
|
||||
void _registerWidget(EntryQueryBar widget) {
|
||||
widget.queryNotifier.addListener(_onQueryChanged);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryQueryBar widget) {
|
||||
widget.queryNotifier.removeListener(_onQueryChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: EntryQueryBar.preferredHeight,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => !selection.isSelecting,
|
||||
builder: (context, editable, child) => QueryBar(
|
||||
queryNotifier: widget.queryNotifier,
|
||||
focusNode: widget.focusNode,
|
||||
hintText: context.l10n.collectionSearchTitlesHintText,
|
||||
editable: editable,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onQueryChanged() {
|
||||
final query = widget.queryNotifier.value;
|
||||
context.read<CollectionLens>().setLiveQuery(query);
|
||||
}
|
||||
}
|
60
lib/widgets/common/action_mixins/entry_editor.dart
Normal file
60
lib/widgets/common/action_mixins/entry_editor.dart
Normal file
|
@ -0,0 +1,60 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/edit_entry_date_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/remove_entry_metadata_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
mixin EntryEditorMixin {
|
||||
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries) async {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final modifier = await showDialog<DateModifier>(
|
||||
context: context,
|
||||
builder: (context) => EditEntryDateDialog(
|
||||
entry: entries.first,
|
||||
),
|
||||
);
|
||||
return modifier;
|
||||
}
|
||||
|
||||
Future<Set<MetadataType>?> selectMetadataToRemove(BuildContext context, Set<AvesEntry> entries) async {
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final types = await showDialog<Set<MetadataType>>(
|
||||
context: context,
|
||||
builder: (context) => RemoveEntryMetadataDialog(
|
||||
showJpegTypes: entries.any((entry) => entry.mimeType == MimeTypes.jpeg),
|
||||
),
|
||||
);
|
||||
if (types == null || types.isEmpty) return null;
|
||||
|
||||
if (entries.any((entry) => entry.isMotionPhoto) && types.contains(MetadataType.xmp)) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
content: Text(context.l10n.removeEntryMetadataMotionPhotoXmpWarningDialogMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed == null || !confirmed) return null;
|
||||
}
|
||||
|
||||
return types;
|
||||
}
|
||||
}
|
|
@ -244,7 +244,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProv
|
|||
// when the user is not dragging the thumb
|
||||
if (!_isDragInProcess) {
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_thumbOffsetNotifier.value = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||
final scrollExtent = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent);
|
||||
_thumbOffsetNotifier.value = thumbMaxScrollExtent > thumbMinScrollExtent ? scrollExtent.clamp(thumbMinScrollExtent, thumbMaxScrollExtent) : thumbMinScrollExtent;
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LabeledCheckbox extends StatefulWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
final String text;
|
||||
|
||||
const LabeledCheckbox({
|
||||
Key? key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.text,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_LabeledCheckboxState createState() => _LabeledCheckboxState();
|
||||
}
|
||||
|
||||
class _LabeledCheckboxState extends State<LabeledCheckbox> {
|
||||
late TapGestureRecognizer _tapRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tapRecognizer = TapGestureRecognizer()..onTap = () => widget.onChanged(!widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tapRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Checkbox(
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: widget.text,
|
||||
recognizer: _tapRecognizer,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,9 +5,10 @@ import 'package:url_launcher/url_launcher.dart';
|
|||
class LinkChip extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final String text;
|
||||
final String url;
|
||||
final String? url;
|
||||
final Color? color;
|
||||
final TextStyle? textStyle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static const borderRadius = BorderRadius.all(Radius.circular(8));
|
||||
|
||||
|
@ -15,22 +16,25 @@ class LinkChip extends StatelessWidget {
|
|||
Key? key,
|
||||
this.leading,
|
||||
required this.text,
|
||||
required this.url,
|
||||
this.url,
|
||||
this.color,
|
||||
this.textStyle,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _url = url;
|
||||
return DefaultTextStyle.merge(
|
||||
style: (textStyle ?? const TextStyle()).copyWith(color: color),
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
onTap: () async {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url);
|
||||
}
|
||||
},
|
||||
onTap: onTap ??
|
||||
() async {
|
||||
if (_url != null && await canLaunch(_url)) {
|
||||
await launch(_url);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
|
|
53
lib/widgets/common/basic/markdown_container.dart
Normal file
53
lib/widgets/common/basic/markdown_container.dart
Normal file
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MarkdownContainer extends StatelessWidget {
|
||||
final String data;
|
||||
|
||||
const MarkdownContainer({
|
||||
Key? key,
|
||||
required this.data,
|
||||
}) : super(key: key);
|
||||
|
||||
static const double maxWidth = 460;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
color: Colors.white10,
|
||||
),
|
||||
constraints: const BoxConstraints(maxWidth: maxWidth),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
scrollbarTheme: const ScrollbarThemeData(
|
||||
isAlwaysShown: true,
|
||||
radius: Radius.circular(16),
|
||||
crossAxisMargin: 6,
|
||||
mainAxisMargin: 16,
|
||||
interactive: true,
|
||||
),
|
||||
),
|
||||
child: Scrollbar(
|
||||
child: Markdown(
|
||||
data: data,
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href != null && await canLaunch(href)) {
|
||||
await launch(href);
|
||||
}
|
||||
},
|
||||
shrinkWrap: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final String text;
|
||||
|
@ -45,3 +47,75 @@ class MenuIconTheme extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PopupMenuItemExpansionPanel<T> extends StatefulWidget {
|
||||
final bool enabled;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<PopupMenuItem<T>> items;
|
||||
|
||||
const PopupMenuItemExpansionPanel({
|
||||
Key? key,
|
||||
this.enabled = true,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.items,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_PopupMenuItemExpansionPanelState createState() => _PopupMenuItemExpansionPanelState<T>();
|
||||
}
|
||||
|
||||
class _PopupMenuItemExpansionPanelState<T> extends State<PopupMenuItemExpansionPanel<T>> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
// ref `_kMenuHorizontalPadding` used in `PopupMenuItem`
|
||||
static const double _horizontalPadding = 16;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
var style = PopupMenuTheme.of(context).textStyle ?? theme.textTheme.subtitle1!;
|
||||
if (!widget.enabled) {
|
||||
style = style.copyWith(color: theme.disabledColor);
|
||||
}
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
|
||||
|
||||
Widget child = ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _isExpanded = !isExpanded);
|
||||
},
|
||||
animationDuration: animationDuration,
|
||||
expandedHeaderPadding: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
children: [
|
||||
ExpansionPanel(
|
||||
headerBuilder: (context, isExpanded) => DefaultTextStyle(
|
||||
style: style,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _horizontalPadding),
|
||||
child: MenuRow(
|
||||
text: widget.title,
|
||||
icon: Icon(widget.icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PopupMenuDivider(height: 0),
|
||||
...widget.items,
|
||||
const PopupMenuDivider(height: 0),
|
||||
],
|
||||
),
|
||||
isExpanded: _isExpanded,
|
||||
canTapOnHeader: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!widget.enabled) {
|
||||
child = IgnorePointer(child: child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,11 +7,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class QueryBar extends StatefulWidget {
|
||||
final ValueNotifier<String> filterNotifier;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
final FocusNode? focusNode;
|
||||
final IconData? icon;
|
||||
final String? hintText;
|
||||
final bool editable;
|
||||
|
||||
const QueryBar({
|
||||
Key? key,
|
||||
required this.filterNotifier,
|
||||
required this.queryNotifier,
|
||||
this.focusNode,
|
||||
this.icon,
|
||||
this.hintText,
|
||||
this.editable = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -22,22 +30,24 @@ class _QueryBarState extends State<QueryBar> {
|
|||
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
||||
late TextEditingController _controller;
|
||||
|
||||
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
|
||||
ValueNotifier<String> get queryNotifier => widget.queryNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: filterNotifier.value);
|
||||
_controller = TextEditingController(text: queryNotifier.value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clearButton = IconButton(
|
||||
icon: const Icon(AIcons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
filterNotifier.value = '';
|
||||
},
|
||||
onPressed: widget.editable
|
||||
? () {
|
||||
_controller.clear();
|
||||
queryNotifier.value = '';
|
||||
}
|
||||
: null,
|
||||
tooltip: context.l10n.clearTooltip,
|
||||
);
|
||||
|
||||
|
@ -47,16 +57,18 @@ class _QueryBarState extends State<QueryBar> {
|
|||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: widget.focusNode ?? FocusNode(),
|
||||
decoration: InputDecoration(
|
||||
icon: const Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: 16),
|
||||
child: Icon(AIcons.search),
|
||||
icon: Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16),
|
||||
child: Icon(widget.icon ?? AIcons.filter),
|
||||
),
|
||||
hintText: MaterialLocalizations.of(context).searchFieldLabel,
|
||||
hintText: widget.hintText ?? MaterialLocalizations.of(context).searchFieldLabel,
|
||||
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: (s) => _debouncer(() => filterNotifier.value = s),
|
||||
onChanged: (s) => _debouncer(() => queryNotifier.value = s.trim()),
|
||||
enabled: widget.editable,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
|
@ -73,7 +85,7 @@ class _QueryBarState extends State<QueryBar> {
|
|||
child: child,
|
||||
),
|
||||
),
|
||||
child: value.text.isNotEmpty ? clearButton : const SizedBox.shrink(),
|
||||
child: value.text.isNotEmpty ? clearButton : const SizedBox(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -14,9 +14,16 @@ class AvesOutlinedButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final style = ButtonStyle(
|
||||
side: MaterialStateProperty.all<BorderSide>(BorderSide(color: Theme.of(context).colorScheme.secondary)),
|
||||
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
|
||||
side: MaterialStateProperty.resolveWith<BorderSide>((states) {
|
||||
return BorderSide(
|
||||
color: states.contains(MaterialState.disabled) ? theme.disabledColor : theme.colorScheme.secondary,
|
||||
);
|
||||
}),
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
return states.contains(MaterialState.disabled) ? theme.disabledColor : Colors.white;
|
||||
}),
|
||||
);
|
||||
return icon != null
|
||||
? OutlinedButton.icon(
|
||||
|
|
|
@ -12,6 +12,7 @@ class MagnifierController {
|
|||
final StreamController<ScaleBoundaries> _scaleBoundariesStreamController = StreamController.broadcast();
|
||||
final StreamController<ScaleStateChange> _scaleStateChangeStreamController = StreamController.broadcast();
|
||||
|
||||
bool _disposed = false;
|
||||
late MagnifierState _currentState, initial, previousState;
|
||||
ScaleBoundaries? _scaleBoundaries;
|
||||
late ScaleStateChange _currentScaleState, previousScaleState;
|
||||
|
@ -54,8 +55,8 @@ class MagnifierController {
|
|||
|
||||
bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut;
|
||||
|
||||
/// Closes streams and removes eventual listeners.
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_stateStreamController.close();
|
||||
_scaleBoundariesStreamController.close();
|
||||
_scaleStateChangeStreamController.close();
|
||||
|
@ -79,23 +80,25 @@ class MagnifierController {
|
|||
}
|
||||
|
||||
void setScaleState(ScaleState newValue, ChangeSource source, {Offset? childFocalPoint}) {
|
||||
if (_currentScaleState.state == newValue) return;
|
||||
if (_disposed || _currentScaleState.state == newValue) return;
|
||||
|
||||
previousScaleState = _currentScaleState;
|
||||
_currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint);
|
||||
_scaleStateChangeStreamController.sink.add(scaleState);
|
||||
_scaleStateChangeStreamController.add(scaleState);
|
||||
}
|
||||
|
||||
void _setState(MagnifierState state) {
|
||||
if (_currentState == state) return;
|
||||
if (_disposed || _currentState == state) return;
|
||||
|
||||
_currentState = state;
|
||||
_stateStreamController.sink.add(state);
|
||||
_stateStreamController.add(state);
|
||||
}
|
||||
|
||||
void setScaleBoundaries(ScaleBoundaries scaleBoundaries) {
|
||||
if (_scaleBoundaries == scaleBoundaries) return;
|
||||
if (_disposed || _scaleBoundaries == scaleBoundaries) return;
|
||||
|
||||
_scaleBoundaries = scaleBoundaries;
|
||||
_scaleBoundariesStreamController.sink.add(scaleBoundaries);
|
||||
_scaleBoundariesStreamController.add(scaleBoundaries);
|
||||
|
||||
if (!isZooming) {
|
||||
update(
|
||||
|
@ -106,9 +109,10 @@ class MagnifierController {
|
|||
}
|
||||
|
||||
void _setScaleState(ScaleStateChange scaleState) {
|
||||
if (_currentScaleState == scaleState) return;
|
||||
if (_disposed || _currentScaleState == scaleState) return;
|
||||
|
||||
_currentScaleState = scaleState;
|
||||
_scaleStateChangeStreamController.sink.add(_currentScaleState);
|
||||
_scaleStateChangeStreamController.add(_currentScaleState);
|
||||
}
|
||||
|
||||
double? getScaleForScaleState(ScaleState scaleState) {
|
||||
|
|
20
lib/widgets/common/providers/query_provider.dart
Normal file
20
lib/widgets/common/providers/query_provider.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
import 'package:aves/model/query.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class QueryProvider extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const QueryProvider({
|
||||
Key? key,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider<Query>(
|
||||
create: (context) => Query(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -94,18 +94,34 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
|||
|
||||
_lastException = null;
|
||||
_providers.clear();
|
||||
|
||||
final highQuality = entry.getThumbnail(extent: extent);
|
||||
ThumbnailProvider? lowQuality;
|
||||
if (widget.progressive && !entry.isSvg) {
|
||||
if (entry.isVideo) {
|
||||
// previously fetched thumbnail
|
||||
final cached = entry.bestCachedThumbnail;
|
||||
final lowQualityExtent = cached.key.extent;
|
||||
if (lowQualityExtent > 0 && lowQualityExtent != extent) {
|
||||
lowQuality = cached;
|
||||
}
|
||||
} else {
|
||||
// default platform thumbnail
|
||||
lowQuality = entry.getThumbnail();
|
||||
}
|
||||
}
|
||||
_providers.addAll([
|
||||
if (widget.progressive && !entry.isSvg)
|
||||
if (lowQuality != null)
|
||||
_ConditionalImageProvider(
|
||||
ScrollAwareImageProvider(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: entry.getThumbnail(),
|
||||
imageProvider: lowQuality,
|
||||
),
|
||||
),
|
||||
_ConditionalImageProvider(
|
||||
ScrollAwareImageProvider(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: entry.getThumbnail(extent: extent),
|
||||
imageProvider: highQuality,
|
||||
),
|
||||
_needSizedProvider,
|
||||
),
|
||||
|
@ -233,7 +249,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
|||
|
||||
if (animate && widget.heroTag != null) {
|
||||
final background = settings.imageBackground;
|
||||
final backgroundColor = background.isColor? background.color : null;
|
||||
final backgroundColor = background.isColor ? background.color : null;
|
||||
image = Hero(
|
||||
tag: widget.heroTag!,
|
||||
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
||||
|
|
|
@ -50,7 +50,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
|||
const DebugAndroidEnvironmentSection(),
|
||||
const DebugCacheSection(),
|
||||
const DebugAppDatabaseSection(),
|
||||
const DebugFirebaseSection(),
|
||||
const DebugErrorReportingSection(),
|
||||
const DebugSettingsSection(),
|
||||
const DebugStorageSection(),
|
||||
],
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/favourites.dart';
|
||||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
|
@ -23,6 +24,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
late Future<List<AddressDetails>> _dbAddressLoader;
|
||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||
late Future<Set<CoverRow>> _dbCoversLoader;
|
||||
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -188,6 +190,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<Set>(
|
||||
future: _dbVideoPlaybackLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('video playback rows: ${snapshot.data!.length}'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => metadataDb.clearVideoPlayback().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -197,12 +220,13 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
|
||||
void _startDbReport() {
|
||||
_dbFileSizeLoader = metadataDb.dbFileSize();
|
||||
_dbEntryLoader = metadataDb.loadEntries();
|
||||
_dbEntryLoader = metadataDb.loadAllEntries();
|
||||
_dbDateLoader = metadataDb.loadDates();
|
||||
_dbMetadataLoader = metadataDb.loadMetadataEntries();
|
||||
_dbAddressLoader = metadataDb.loadAddresses();
|
||||
_dbFavouritesLoader = metadataDb.loadFavourites();
|
||||
_dbCoversLoader = metadataDb.loadCovers();
|
||||
_dbMetadataLoader = metadataDb.loadAllMetadataEntries();
|
||||
_dbAddressLoader = metadataDb.loadAllAddresses();
|
||||
_dbFavouritesLoader = metadataDb.loadAllFavourites();
|
||||
_dbCoversLoader = metadataDb.loadAllCovers();
|
||||
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
|
|
@ -2,11 +2,10 @@ import 'package:aves/services/android_debug_service.dart';
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DebugFirebaseSection extends StatelessWidget {
|
||||
const DebugFirebaseSection({Key? key}) : super(key: key);
|
||||
class DebugErrorReportingSection extends StatelessWidget {
|
||||
const DebugErrorReportingSection({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -51,10 +50,7 @@ class DebugFirebaseSection extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
info: {
|
||||
'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}',
|
||||
'Crashlytics collection enabled': '${reportService.isCollectionEnabled}',
|
||||
},
|
||||
info: reportService.state,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AvesDialog extends AlertDialog {
|
||||
static const EdgeInsets contentHorizontalPadding = EdgeInsets.symmetric(horizontal: 24);
|
||||
static const double defaultHorizontalContentPadding = 24;
|
||||
static const double controlCaptionPadding = 16;
|
||||
static const double borderWidth = 1.0;
|
||||
|
||||
|
@ -16,6 +16,7 @@ class AvesDialog extends AlertDialog {
|
|||
ScrollController? scrollController,
|
||||
List<Widget>? scrollableContent,
|
||||
bool hasScrollBar = true,
|
||||
double horizontalContentPadding = defaultHorizontalContentPadding,
|
||||
Widget? content,
|
||||
required List<Widget> actions,
|
||||
}) : assert((scrollableContent != null) ^ (content != null)),
|
||||
|
@ -34,7 +35,7 @@ class AvesDialog extends AlertDialog {
|
|||
// and overflow feedback ignores the dialog shape,
|
||||
// so we restrict scrolling to the content instead
|
||||
content: _buildContent(context, scrollController, scrollableContent, hasScrollBar, content),
|
||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
contentPadding: scrollableContent != null ? EdgeInsets.zero : EdgeInsets.fromLTRB(horizontalContentPadding, 20, horizontalContentPadding, 0),
|
||||
actions: actions,
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
|
@ -115,7 +116,7 @@ class DialogTitle extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth),
|
||||
|
@ -127,6 +128,7 @@ class DialogTitle extends StatelessWidget {
|
|||
fontWeight: FontWeight.normal,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -123,7 +123,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
|
|||
builder: (context) => ItemPickDialog(
|
||||
collection: CollectionLens(
|
||||
source: context.read<CollectionSource>(),
|
||||
filters: [filter],
|
||||
filters: {filter},
|
||||
),
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
|
|
|
@ -44,6 +44,8 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
|
||||
|
||||
final volumeTiles = <Widget>[];
|
||||
if (_allVolumes.length > 1) {
|
||||
final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary);
|
||||
|
@ -52,7 +54,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
final otherVolumes = (byPrimary[false] ?? [])..sort(compare);
|
||||
volumeTiles.addAll([
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(top: 20),
|
||||
padding: contentHorizontalPadding + const EdgeInsets.only(top: 20),
|
||||
child: Text(context.l10n.newAlbumDialogStorageLabel),
|
||||
),
|
||||
...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)),
|
||||
|
@ -68,7 +70,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
scrollableContent: [
|
||||
...volumeTiles,
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(bottom: 8),
|
||||
padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _existsNotifier,
|
||||
builder: (context, exists, child) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -44,125 +45,141 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
void _updateAction(DateEditAction? action) {
|
||||
if (action == null) return;
|
||||
setState(() => _action = action);
|
||||
}
|
||||
return MediaQueryDataProvider(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
final locale = l10n.localeName;
|
||||
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
|
||||
|
||||
Widget _tileText(String text) => Text(
|
||||
text,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
void _updateAction(DateEditAction? action) {
|
||||
if (action == null) return;
|
||||
setState(() => _action = action);
|
||||
}
|
||||
|
||||
final setTile = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.set,
|
||||
Widget _tileText(String text) => Text(
|
||||
text,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
);
|
||||
|
||||
final setTile = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.set,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogSet),
|
||||
subtitle: Text(formatDateTime(_dateTime, locale, use24hour)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.set ? _editDate : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final shiftTile = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.shift,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogShift),
|
||||
subtitle: Text(_formatShiftDuration()),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.shift ? _editShift : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final extractFromTitleTile = RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.extractFromTitle,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogSet),
|
||||
subtitle: Text(formatDateTime(_dateTime, l10n.localeName)),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.set ? _editDate : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final shiftTile = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.shift,
|
||||
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
|
||||
);
|
||||
final clearTile = RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.clear,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogShift),
|
||||
subtitle: Text(_formatShiftDuration()),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: IconButton(
|
||||
icon: const Icon(AIcons.edit),
|
||||
onPressed: _action == DateEditAction.shift ? _editShift : null,
|
||||
tooltip: l10n.changeTooltip,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final clearTile = RadioListTile<DateEditAction>(
|
||||
value: DateEditAction.clear,
|
||||
groupValue: _action,
|
||||
onChanged: _updateAction,
|
||||
title: _tileText(l10n.editEntryDateDialogClear),
|
||||
);
|
||||
title: _tileText(l10n.editEntryDateDialogClear),
|
||||
);
|
||||
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
|
||||
final theme = Theme.of(context);
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
textTheme: theme.textTheme.copyWith(
|
||||
// dense style font for tile subtitles, without modifying title font
|
||||
bodyText2: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
child: AvesDialog(
|
||||
context: context,
|
||||
title: l10n.editEntryDateDialogTitle,
|
||||
scrollableContent: [
|
||||
setTile,
|
||||
shiftTile,
|
||||
clearTile,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _showOptions = !isExpanded);
|
||||
},
|
||||
animationDuration: animationDuration,
|
||||
expandedHeaderPadding: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
children: [
|
||||
ExpansionPanel(
|
||||
headerBuilder: (context, isExpanded) => ListTile(
|
||||
title: Text(l10n.editEntryDateDialogFieldSelection),
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
|
||||
final theme = Theme.of(context);
|
||||
return Theme(
|
||||
data: theme.copyWith(
|
||||
textTheme: theme.textTheme.copyWith(
|
||||
// dense style font for tile subtitles, without modifying title font
|
||||
bodyText2: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
child: AvesDialog(
|
||||
context: context,
|
||||
title: l10n.editEntryDateDialogTitle,
|
||||
scrollableContent: [
|
||||
setTile,
|
||||
shiftTile,
|
||||
extractFromTitleTile,
|
||||
clearTile,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _showOptions = !isExpanded);
|
||||
},
|
||||
animationDuration: animationDuration,
|
||||
expandedHeaderPadding: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
children: [
|
||||
ExpansionPanel(
|
||||
headerBuilder: (context, isExpanded) => ListTile(
|
||||
title: Text(l10n.editEntryDateDialogFieldSelection),
|
||||
),
|
||||
body: Column(
|
||||
children: DateModifier.allDateFields
|
||||
.map((field) => SwitchListTile(
|
||||
value: _fields.contains(field),
|
||||
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
|
||||
title: Text(_fieldTitle(field)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
isExpanded: _showOptions,
|
||||
canTapOnHeader: true,
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: DateModifier.allDateFields
|
||||
.map((field) => SwitchListTile(
|
||||
value: _fields.contains(field),
|
||||
onChanged: (selected) => setState(() => selected ? _fields.add(field) : _fields.remove(field)),
|
||||
title: Text(_fieldTitle(field)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
isExpanded: _showOptions,
|
||||
canTapOnHeader: true,
|
||||
backgroundColor: Theme.of(context).dialogBackgroundColor,
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text(l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -233,6 +250,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
|||
case DateEditAction.shift:
|
||||
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes);
|
||||
break;
|
||||
case DateEditAction.extractFromTitle:
|
||||
case DateEditAction.clear:
|
||||
modifier = DateModifier(_action, _fields);
|
||||
break;
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/widgets/collection/collection_grid.dart';
|
|||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/query_provider.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -39,13 +40,15 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
|
|||
child: MediaQueryDataProvider(
|
||||
child: Scaffold(
|
||||
body: SelectionProvider<AvesEntry>(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
child: QueryProvider(
|
||||
child: GestureAreaProtectorStack(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ChangeNotifierProvider<CollectionLens>.value(
|
||||
value: collection,
|
||||
child: const CollectionGrid(
|
||||
settingsRouteKey: CollectionPage.routeName,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/ref/brand_colors.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -14,11 +12,11 @@ import 'package:provider/provider.dart';
|
|||
import 'aves_dialog.dart';
|
||||
|
||||
class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final bool showJpegTypes;
|
||||
|
||||
const RemoveEntryMetadataDialog({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.showJpegTypes,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -31,14 +29,12 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
|||
bool _showMore = false;
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
AvesEntry get entry => widget.entry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final byMain = groupBy([
|
||||
...MetadataTypes.common,
|
||||
if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg,
|
||||
if (widget.showJpegTypes) ...MetadataTypes.jpeg,
|
||||
], MetadataTypes.main.contains);
|
||||
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
|
||||
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue