Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-11-08 17:57:59 +09:00
commit cb3f97e286
166 changed files with 4433 additions and 1549 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="app_name">아베스 [Debug]</string>
</resources>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"))
}
}
}

View file

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

View file

@ -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()
}
}
}

View file

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

View file

@ -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()
}
}

View file

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

View file

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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Поиск</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>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="app_name">아베스 [Profile]</string>
</resources>

View file

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

View file

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

View file

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

@ -0,0 +1,5 @@
enum AppFlavor { play, izzy }
extension ExtraAppFlavor on AppFlavor {
bool get canEnableErrorReporting => this == AppFlavor.play;
}

View file

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

View file

@ -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.",

View file

@ -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
View 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": "Источник"
}

View file

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

@ -0,0 +1,6 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/main_common.dart';
void main() {
mainCommon(AppFlavor.play);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ enum MetadataField {
enum DateEditAction {
set,
shift,
extractFromTitle,
clear,
}

View file

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

View file

@ -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
View 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('');
}

View file

@ -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),
];
}
}

View file

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

View file

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

View file

@ -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();

View file

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

View file

@ -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();

View file

@ -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();

View file

@ -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();

View file

@ -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})$');

View 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,
};
}

View file

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

View file

@ -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());
}

View file

@ -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,
};
}));

View file

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

View file

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

View file

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

View file

@ -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');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
),
);
}
}

View file

@ -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');

View file

@ -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),
],
),
);

View file

@ -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();

View 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),
);
},
),
),
),
);
}
}

View file

@ -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();

View file

@ -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,
);
}
}

View file

@ -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'),
),
),
),
),

View file

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

View file

@ -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();
}

View 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);
}
}

View 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;
}
}

View file

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

View file

@ -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,
),
],
),
);
}
}

View file

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

View 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,
),
),
),
),
);
}
}

View file

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

View file

@ -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(),
),
),
)

View file

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

View file

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

View 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,
);
}
}

View file

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

View file

@ -50,7 +50,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
const DebugAndroidEnvironmentSection(),
const DebugCacheSection(),
const DebugAppDatabaseSection(),
const DebugFirebaseSection(),
const DebugErrorReportingSection(),
const DebugSettingsSection(),
const DebugStorageSection(),
],

View file

@ -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(() {});
}

View file

@ -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,
),
)
],

View file

@ -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,
),
);
}

View file

@ -123,7 +123,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
builder: (context) => ItemPickDialog(
collection: CollectionLens(
source: context.read<CollectionSource>(),
filters: [filter],
filters: {filter},
),
),
fullscreenDialog: true,

View file

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

View file

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

View file

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

View file

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