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 echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build appbundle --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json mkdir outputs
flutter build apk --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json (cd scripts/; ./apply_flavor_play.sh)
flutter build apk --flavor byAbi --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json 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 rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks
@ -64,14 +70,14 @@ jobs:
- name: Create a release with the APK and App Bundle. - name: Create a release with the APK and App Bundle.
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: 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 }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload app bundle - name: Upload app bundle
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: appbundle name: appbundle
path: build/app/outputs/bundle/universalRelease/app-universal-release.aab path: outputs/app-play-release.aab
release: release:
name: Create beta release on Play Store. name: Create beta release on Play Store.
@ -90,7 +96,7 @@ jobs:
with: with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
packageName: deckers.thibault.aves packageName: deckers.thibault.aves
releaseFiles: app-universal-release.aab releaseFiles: app-play-release.aab
track: beta track: beta
status: completed status: completed
whatsNewDirectory: whatsnew whatsNewDirectory: whatsnew

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ### Added
- Collection: use a foreground service when scanning many items - 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. 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 ### 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! ❤️ 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 ## 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. 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 [Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check [Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -2,8 +2,6 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt' id 'kotlin-kapt'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
} }
def appId = "deckers.thibault.aves" def appId = "deckers.thibault.aves"
@ -77,18 +75,25 @@ android {
} }
} }
// the "splitting" dimension and its flavors are only for building purposes: flavorDimensions "store"
// 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"
productFlavors { productFlavors {
universal { play {
dimension "splitting" // Google Play
dimension "store"
ext.useCrashlytics = true
// generate a universal APK without x86 native libs
ext.useNdkAbiFilters = true
} }
byAbi { izzy {
dimension "splitting" // 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,8 +113,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
def runTasks = gradle.startParameter.taskNames.toString().toLowerCase() android.productFlavors.each { flavor ->
if (runTasks.contains("universal")) { def tasks = gradle.startParameter.taskNames.toString().toLowerCase()
if (tasks.contains(flavor.name) && flavor.ext.useNdkAbiFilters) {
release { release {
// specify architectures, to specifically exclude native libs for x86, // specify architectures, to specifically exclude native libs for x86,
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
@ -120,6 +126,7 @@ android {
} }
} }
} }
}
} }
flutter { flutter {
@ -132,7 +139,7 @@ repositories {
} }
dependencies { 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.core:core-ktx:1.6.0'
implementation 'androidx.exifinterface:exifinterface:1.3.3' implementation 'androidx.exifinterface:exifinterface:1.3.3'
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
@ -150,3 +157,12 @@ dependencies {
compileOnly rootProject.findProject(':streams_channel') 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.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler 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.FlutterUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@ -155,12 +154,11 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private inner class ServiceHandler(looper: Looper) : Handler(looper) { private inner class ServiceHandler(looper: Looper) : Handler(looper) {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
val context = this@AnalysisService
val data = msg.data val data = msg.data
when (data.getString(KEY_COMMAND)) { when (data.getString(KEY_COMMAND)) {
COMMAND_START -> { COMMAND_START -> {
runBlocking { runBlocking {
context.runOnUiThread { FlutterUtils.runOnUiThread {
val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() } val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
backgroundChannel?.invokeMethod( backgroundChannel?.invokeMethod(
"start", hashMapOf( "start", hashMapOf(
@ -174,7 +172,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
COMMAND_STOP -> { COMMAND_STOP -> {
// unconditionally stop the service // unconditionally stop the service
runBlocking { runBlocking {
context.runOnUiThread { FlutterUtils.runOnUiThread {
backgroundChannel?.invokeMethod("stop", null) backgroundChannel?.invokeMethod("stop", null)
} }
} }

View file

@ -311,7 +311,10 @@ class MainActivity : FlutterActivity() {
var errorStreamHandler: ErrorStreamHandler? = null 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.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.text.format.DateFormat
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.resourceUri 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.FlutterUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@ -79,10 +79,11 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
return suspendCoroutine { cont -> return suspendCoroutine { cont ->
GlobalScope.launch { GlobalScope.launch {
context.runOnUiThread { FlutterUtils.runOnUiThread {
backgroundChannel.invokeMethod("getSuggestions", hashMapOf( backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
"query" to query, "query" to query,
"locale" to Locale.getDefault().toString(), "locale" to Locale.getDefault().toString(),
"use24hour" to DateFormat.is24HourFormat(context),
), object : MethodChannel.Result { ), object : MethodChannel.Result {
override fun success(result: Any?) { override fun success(result: Any?) {
@Suppress("unchecked_cast") @Suppress("unchecked_cast")

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import deckers.thibault.aves.utils.FlutterUtils
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
@ -15,9 +16,11 @@ class ErrorStreamHandler : EventChannel.StreamHandler {
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}
fun notifyError(error: String) { suspend fun notifyError(error: String) {
FlutterUtils.runOnUiThread {
eventSink?.success(error) eventSink?.success(error)
} }
}
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/error" const val CHANNEL = "deckers.thibault/aves/error"

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? val path = args["path"] as String?
if (path == null) { if (path == null) {
error("requestDirectoryAccess-args", "failed because of missing arguments", 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 com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.metadata.MultiTrackMedia import deckers.thibault.aves.metadata.MultiTrackMedia
@GlideModule @GlideModule
class MultiTrackImageGlideModule : LibraryGlideModule() { class MultiTrackImageGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { 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.content.Context
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
@ -58,16 +57,13 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
try { try {
var bytes = retriever.embeddedPicture var bytes = retriever.embeddedPicture
if (bytes == null) { if (bytes == null) {
// try to match the thumbnails returned by the content resolver / Media Store // there is no consistent strategy across devices to match
// the following strategies are from empirical evidence from a few test devices: // the thumbnails returned by the content resolver / Media Store
// - API 29: sync frame closest to the middle // so we derive one in an arbitrary way
// - API 26/27: default representative frame at any time position
var timeMillis: Long? = null var timeMillis: Long? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
if (durationMillis != null) { if (durationMillis != null) {
timeMillis = durationMillis / 2 timeMillis = if (durationMillis < 15000) 0 else 15000
}
} }
val frame = if (timeMillis != null) { val frame = if (timeMillis != null) {
retriever.getFrameAtTime(timeMillis * 1000) retriever.getFrameAtTime(timeMillis * 1000)

View file

@ -9,7 +9,6 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
@ -734,7 +733,7 @@ abstract class ImageProvider {
targetUri: Uri, targetUri: Uri,
targetPath: String 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") val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
} else { } else {
@ -758,14 +757,17 @@ abstract class ImageProvider {
// used when skipping a move/creation op because the target file already exists // used when skipping a move/creation op because the target file already exists
val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true) val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true)
@RequiresApi(Build.VERSION_CODES.Q)
fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean { fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType)
val pid = Binder.getCallingPid() val pid = Binder.getCallingPid()
val uid = Binder.getCallingUid() val uid = Binder.getCallingUid()
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED 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.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
@ -226,47 +225,42 @@ class MediaStoreImageProvider : ImageProvider() {
return found 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 private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI // `uri` is a media URI, not a document URI
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q path ?: throw Exception("failed to delete file because path is null")
&& isMediaUriPermissionGranted(activity, uri, mimeType))
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()` // 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, // 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 // 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)) {
Log.d(LOG_TAG, "delete document at uri=$uri path=$path") Log.d(LOG_TAG, "delete document at uri=$uri path=$path")
val df = StorageUtils.getDocumentFile(activity, path, uri) val df = StorageUtils.getDocumentFile(activity, path, uri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return if (df != null && df.delete()) {
throw Exception("failed to delete file with df=$df") scanObsoletePath(activity, path, mimeType)
return
}
throw Exception("failed to delete document with df=$df")
} }
} }
try { 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 if (activity.contentResolver.delete(uri, null, null) > 0) return
throw Exception("failed to delete row from content provider")
} catch (securityException: SecurityException) { } catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory, // even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+ // the delete request may yield a `RecoverableSecurityException` on Android 10+
@ -291,7 +285,6 @@ class MediaStoreImageProvider : ImageProvider() {
throw securityException throw securityException
} }
} }
throw Exception("failed to delete row from content provider")
} }
override suspend fun moveMultiple( override suspend fun moveMultiple(
@ -330,6 +323,7 @@ class MediaStoreImageProvider : ImageProvider() {
// with a path, and retrieve its content URI, but: // 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 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 // - 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?) // - 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 // - 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 // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
@ -513,31 +507,30 @@ class MediaStoreImageProvider : ImageProvider() {
): FieldMap { ): FieldMap {
val oldFile = File(oldPath) val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFileName) val newFile = File(oldFile.parent, newFileName)
if (oldFile == newFile) { return when {
// nothing to do oldFile == newFile -> skippedFieldMap
return 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)
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)
} }
} }
@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun renameSingleByMediaStore( private suspend fun renameSingleByMediaStore(
activity: Activity, activity: Activity,
mimeType: String, mimeType: String,
mediaUri: Uri, mediaUri: Uri,
newFile: File newFile: File
): FieldMap { ): FieldMap {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
throw Exception("unsupported Android version")
}
val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType) val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType)
// `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME` // `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) { if (activity.contentResolver.update(uri, tempValues, null, null) == 0) {
throw Exception("failed to update fields for uri=$uri") throw Exception("failed to update fields for uri=$uri")
} }
@ -567,39 +560,25 @@ class MediaStoreImageProvider : ImageProvider() {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false
if (!renamed) { 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) 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)
}
}
} }
return newFields 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")
}
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) { override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
@ -658,11 +637,7 @@ class MediaStoreImageProvider : ImageProvider() {
return null return null
} }
if (newUri == null) { if (newUri != null) {
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
return@scanFile
}
var contentUri: Uri? = null var contentUri: Uri? = null
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") // `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") // but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
@ -683,6 +658,9 @@ class MediaStoreImageProvider : ImageProvider() {
} else { } else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
} }
} else {
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.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object ContextUtils { object ContextUtils {
fun Context.resourceUri(resourceId: Int): Uri = with(resources) { fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
@ -20,19 +16,6 @@ object ContextUtils {
.build() .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 { fun Context.isMyServiceRunning(serviceClass: Class<out Service>): Boolean {
val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
am ?: return false am ?: return false

View file

@ -1,13 +1,16 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
import io.flutter.FlutterInjector import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation import io.flutter.view.FlutterCallbackInformation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object FlutterUtils { object FlutterUtils {
private val LOG_TAG = LogUtils.createTag<FlutterUtils>() private val LOG_TAG = LogUtils.createTag<FlutterUtils>()
@ -20,7 +23,7 @@ object FlutterUtils {
} }
lateinit var flutterLoader: FlutterLoader lateinit var flutterLoader: FlutterLoader
context.runOnUiThread { FlutterUtils.runOnUiThread {
// initialization must happen on the main thread // initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply { flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context) startInitialization(context)
@ -39,11 +42,25 @@ object FlutterUtils {
flutterLoader.findAppBundlePath(), flutterLoader.findAppBundlePath(),
callbackInfo callbackInfo
) )
context.runOnUiThread { runOnUiThread {
val engine = FlutterEngine(context).apply { val engine = FlutterEngine(context).apply {
dartExecutor.executeDartCallback(args) dartExecutor.executeDartCallback(args)
} }
engineSetter(engine) 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 // extensions
// among other refs:
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
fun extensionFor(mimeType: String): String? = when (mimeType) { fun extensionFor(mimeType: String): String? = when (mimeType) {
ARW -> ".arw" ARW -> ".arw"
AVI, AVI_VND -> ".avi" AVI, AVI_VND -> ".avi"

View file

@ -31,13 +31,18 @@ object PermissionManager {
) )
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @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") Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
var intent: Intent? = null var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager 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 // fallback to basic open document tree intent
@ -49,7 +54,7 @@ object PermissionManager {
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
} else { } else {
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent") MainActivity.notifyError("failed to resolve activity for intent=$intent")
onDenied() onDenied()
} }
} }

View file

@ -394,6 +394,8 @@ object StorageUtils {
* Misc * Misc
*/ */
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
fun requireAccessPermission(context: Context, anyPath: String): Boolean { fun requireAccessPermission(context: Context, anyPath: String): Boolean {
// on Android R, we should always require access permission, even on primary volume // on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { 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> <resources>
<string name="app_name">아베스</string> <string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</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() mavenCentral()
} }
dependencies { 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" 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.gms:google-services:4.3.10'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
} }

View file

@ -1,17 +1,25 @@
# Terms of Service ## Terms of Service
Aves is an open-source gallery and metadata explorer app allowing you to access and manage your local photos.
“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. You must use the app for legal, authorized and acceptable purposes.
# Disclaimer ## Disclaimer
This app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
# Privacy policy The app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
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.
__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 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.
[Sources](https://github.com/deckerst/aves)
[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: preferred-supported-locales:
- en - 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 { extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal; 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 hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal; bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;

View file

@ -3,8 +3,7 @@
"@appName": {}, "@appName": {},
"welcomeMessage": "Welcome to Aves", "welcomeMessage": "Welcome to Aves",
"@welcomeMessage": {}, "@welcomeMessage": {},
"welcomeCrashReportToggle": "Allow anonymous error reporting (optional)", "welcomeOptional": "Optional",
"@welcomeCrashReportToggle": {},
"welcomeTermsToggle": "I agree to the terms and conditions", "welcomeTermsToggle": "I agree to the terms and conditions",
"@welcomeTermsToggle": {}, "@welcomeTermsToggle": {},
"itemCount": "{count, plural, =1{1 item} other{{count} items}}", "itemCount": "{count, plural, =1{1 item} other{{count} items}}",
@ -176,6 +175,25 @@
"@coordinateFormatDms": {}, "@coordinateFormatDms": {},
"coordinateFormatDecimal": "Decimal degrees", "coordinateFormatDecimal": "Decimal degrees",
"@coordinateFormatDecimal": {}, "@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": "Metric",
"@unitSystemMetric": {}, "@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": "Some files in the destination folder have the same name.",
"@nameConflictDialogSingleSourceMessage": {}, "@nameConflictDialogSingleSourceMessage": {},
"nameConflictDialogMultipleSourceMessage": "Some files have the same name.", "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": "Set Cover",
"@setCoverDialogTitle": {}, "@setCoverDialogTitle": {},
"setCoverDialogLatest": "Latest item", "setCoverDialogLatest": "Latest item",
@ -360,6 +401,8 @@
"@editEntryDateDialogSet": {}, "@editEntryDateDialogSet": {},
"editEntryDateDialogShift": "Shift", "editEntryDateDialogShift": "Shift",
"@editEntryDateDialogShift": {}, "@editEntryDateDialogShift": {},
"editEntryDateDialogExtractFromTitle": "Extract from title",
"@editEntryDateDialogExtractFromTitle": {},
"editEntryDateDialogClear": "Clear", "editEntryDateDialogClear": "Clear",
"@editEntryDateDialogClear": {}, "@editEntryDateDialogClear": {},
"editEntryDateDialogFieldSelection": "Field selection", "editEntryDateDialogFieldSelection": "Field selection",
@ -374,7 +417,7 @@
"removeEntryMetadataDialogMore": "More", "removeEntryMetadataDialogMore": "More",
"@removeEntryMetadataDialogMore": {}, "@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": {}, "@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
"videoSpeedDialogLabel": "Playback speed", "videoSpeedDialogLabel": "Playback speed",
@ -419,6 +462,8 @@
"@aboutLinkSources": {}, "@aboutLinkSources": {},
"aboutLinkLicense": "License", "aboutLinkLicense": "License",
"@aboutLinkLicense": {}, "@aboutLinkLicense": {},
"aboutLinkPolicy": "Privacy Policy",
"@aboutLinkPolicy": {},
"aboutUpdate": "New Version Available", "aboutUpdate": "New Version Available",
"@aboutUpdate": {}, "@aboutUpdate": {},
@ -454,6 +499,8 @@
"@aboutCreditsWorldAtlas1": {}, "@aboutCreditsWorldAtlas1": {},
"aboutCreditsWorldAtlas2": "under ISC License.", "aboutCreditsWorldAtlas2": "under ISC License.",
"@aboutCreditsWorldAtlas2": {}, "@aboutCreditsWorldAtlas2": {},
"aboutCreditsTranslators": "Translators:",
"@aboutCreditsTranslators": {},
"aboutLicenses": "Open-Source Licenses", "aboutLicenses": "Open-Source Licenses",
"@aboutLicenses": {}, "@aboutLicenses": {},
@ -470,6 +517,9 @@
"aboutLicensesShowAllButtonLabel": "Show All Licenses", "aboutLicensesShowAllButtonLabel": "Show All Licenses",
"@aboutLicensesShowAllButtonLabel": {}, "@aboutLicensesShowAllButtonLabel": {},
"policyPageTitle": "Privacy Policy",
"@policyPageTitle": {},
"collectionPageTitle": "Collection", "collectionPageTitle": "Collection",
"@collectionPageTitle": {}, "@collectionPageTitle": {},
"collectionPickPageTitle": "Pick", "collectionPickPageTitle": "Pick",
@ -481,6 +531,10 @@
} }
}, },
"collectionActionShowTitleSearch": "Show title filter",
"@collectionActionShowTitleSearch": {},
"collectionActionHideTitleSearch": "Hide title filter",
"@collectionActionHideTitleSearch": {},
"collectionActionAddShortcut": "Add shortcut", "collectionActionAddShortcut": "Add shortcut",
"@collectionActionAddShortcut": {}, "@collectionActionAddShortcut": {},
"collectionActionCopy": "Copy to album", "collectionActionCopy": "Copy to album",
@ -489,6 +543,11 @@
"@collectionActionMove": {}, "@collectionActionMove": {},
"collectionActionRescan": "Rescan", "collectionActionRescan": "Rescan",
"@collectionActionRescan": {}, "@collectionActionRescan": {},
"collectionActionEdit": "Edit",
"@collectionActionEdit": {},
"collectionSearchTitlesHintText": "Search titles",
"@collectionSearchTitlesHintText": {},
"collectionSortTitle": "Sort", "collectionSortTitle": "Sort",
"@collectionSortTitle": {}, "@collectionSortTitle": {},
@ -536,6 +595,12 @@
"count": {} "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": "{count, plural, =1{Failed to export 1 page} other{Failed to export {count} pages}}",
"@collectionExportFailureFeedback": { "@collectionExportFailureFeedback": {
"placeholders": { "placeholders": {
@ -554,6 +619,12 @@
"count": {} "count": {}
} }
}, },
"collectionEditSuccessFeedback": "{count, plural, =1{Edited 1 item} other{Edited {count} items}}",
"@collectionEditSuccessFeedback": {
"placeholders": {
"count": {}
}
},
"collectionEmptyFavourites": "No favourites", "collectionEmptyFavourites": "No favourites",
"@collectionEmptyFavourites": {}, "@collectionEmptyFavourites": {},
@ -705,10 +776,16 @@
"settingsThumbnailShowVideoDuration": "Show video duration", "settingsThumbnailShowVideoDuration": "Show video duration",
"@settingsThumbnailShowVideoDuration": {}, "@settingsThumbnailShowVideoDuration": {},
"settingsCollectionSelectionQuickActionsTile": "Quick actions for item selection", "settingsCollectionQuickActionsTile": "Quick actions",
"@settingsCollectionSelectionQuickActionsTile": {}, "@settingsCollectionQuickActionsTile": {},
"settingsCollectionSelectionQuickActionEditorTitle": "Quick Actions", "settingsCollectionQuickActionEditorTitle": "Quick Actions",
"@settingsCollectionSelectionQuickActionEditorTitle": {}, "@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": "Touch and hold to move buttons and select which actions are displayed when selecting items.",
"@settingsCollectionSelectionQuickActionEditorBanner": {}, "@settingsCollectionSelectionQuickActionEditorBanner": {},
@ -794,8 +871,12 @@
"settingsSectionPrivacy": "Privacy", "settingsSectionPrivacy": "Privacy",
"@settingsSectionPrivacy": {}, "@settingsSectionPrivacy": {},
"settingsEnableErrorReporting": "Allow anonymous error reporting", "settingsAllowInstalledAppAccess": "Allow access to app inventory",
"@settingsEnableErrorReporting": {}, "@settingsAllowInstalledAppAccess": {},
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",
"@settingsAllowInstalledAppAccessSubtitle": {},
"settingsAllowErrorReporting": "Allow anonymous error reporting",
"@settingsAllowErrorReporting": {},
"settingsSaveSearchHistory": "Save search history", "settingsSaveSearchHistory": "Save search history",
"@settingsSaveSearchHistory": {}, "@settingsSaveSearchHistory": {},
@ -856,18 +937,6 @@
"statsPageTitle": "Stats", "statsPageTitle": "Stats",
"@statsPageTitle": {}, "@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": "{count, plural, =1{1 item with location} other{{count} items with location}}",
"@statsWithGps": { "@statsWithGps": {
"placeholders": { "placeholders": {
@ -883,8 +952,6 @@
"viewerOpenPanoramaButtonLabel": "OPEN PANORAMA", "viewerOpenPanoramaButtonLabel": "OPEN PANORAMA",
"@viewerOpenPanoramaButtonLabel": {}, "@viewerOpenPanoramaButtonLabel": {},
"viewerOpenTooltip": "Open",
"@viewerOpenTooltip": {},
"viewerErrorUnknown": "Oops!", "viewerErrorUnknown": "Oops!",
"@viewerErrorUnknown": {}, "@viewerErrorUnknown": {},
"viewerErrorDoesNotExist": "The file no longer exists.", "viewerErrorDoesNotExist": "The file no longer exists.",

View file

@ -1,7 +1,7 @@
{ {
"appName": "아베스", "appName": "아베스",
"welcomeMessage": "아베스 사용을 환영합니다", "welcomeMessage": "아베스 사용을 환영합니다",
"welcomeCrashReportToggle": "오류 보고서를 보내는 것에 동의합니다 (선택)", "welcomeOptional": "선택",
"welcomeTermsToggle": "이용약관에 동의합니다", "welcomeTermsToggle": "이용약관에 동의합니다",
"itemCount": "{count, plural, other{{count}개}}", "itemCount": "{count, plural, other{{count}개}}",
@ -87,6 +87,11 @@
"coordinateFormatDms": "도분초", "coordinateFormatDms": "도분초",
"coordinateFormatDecimal": "소수점", "coordinateFormatDecimal": "소수점",
"coordinateDms": "{direction} {coordinate}",
"coordinateDmsNorth": "북위",
"coordinateDmsSouth": "남위",
"coordinateDmsEast": "동경",
"coordinateDmsWest": "서경",
"unitSystemMetric": "미터법", "unitSystemMetric": "미터법",
"unitSystemImperial": "야드파운드법", "unitSystemImperial": "야드파운드법",
@ -130,6 +135,9 @@
"notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogTitle": "저장공간 부족",
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
"unsupportedTypeDialogTitle": "미지원 형식",
"unsupportedTypeDialogMessage": "{count, plural, other{이 작업은 다음 항목의 형식을 지원하지 않습니다: {types}.}}",
"nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.", "nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.",
"nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.", "nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.",
@ -141,6 +149,10 @@
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}", "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
"videoResumeDialogMessage": "{time}부터 재개하시겠습니까?",
"videoStartOverButtonLabel": "처음부터",
"videoResumeButtonLabel": "재개",
"setCoverDialogTitle": "대표 이미지 변경", "setCoverDialogTitle": "대표 이미지 변경",
"setCoverDialogLatest": "최근 항목", "setCoverDialogLatest": "최근 항목",
"setCoverDialogCustom": "직접 설정", "setCoverDialogCustom": "직접 설정",
@ -165,6 +177,7 @@
"editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogTitle": "날짜 및 시간",
"editEntryDateDialogSet": "편집", "editEntryDateDialogSet": "편집",
"editEntryDateDialogShift": "시간 이동", "editEntryDateDialogShift": "시간 이동",
"editEntryDateDialogExtractFromTitle": "제목에서 추출",
"editEntryDateDialogClear": "삭제", "editEntryDateDialogClear": "삭제",
"editEntryDateDialogFieldSelection": "필드 선택", "editEntryDateDialogFieldSelection": "필드 선택",
"editEntryDateDialogHours": "시간", "editEntryDateDialogHours": "시간",
@ -198,6 +211,7 @@
"aboutPageTitle": "앱 정보", "aboutPageTitle": "앱 정보",
"aboutLinkSources": "소스 코드", "aboutLinkSources": "소스 코드",
"aboutLinkLicense": "라이선스", "aboutLinkLicense": "라이선스",
"aboutLinkPolicy": "개인정보 보호정책",
"aboutUpdate": "업데이트 사용 가능", "aboutUpdate": "업데이트 사용 가능",
"aboutUpdateLinks1": "앱의 최신 버전을", "aboutUpdateLinks1": "앱의 최신 버전을",
@ -217,6 +231,7 @@
"aboutCredits": "크레딧", "aboutCredits": "크레딧",
"aboutCreditsWorldAtlas1": "이 앱은", "aboutCreditsWorldAtlas1": "이 앱은",
"aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.",
"aboutCreditsTranslators": "번역가:",
"aboutLicenses": "오픈 소스 라이선스", "aboutLicenses": "오픈 소스 라이선스",
"aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.",
@ -226,14 +241,21 @@
"aboutLicensesDartPackages": "다트 패키지", "aboutLicensesDartPackages": "다트 패키지",
"aboutLicensesShowAllButtonLabel": "라이선스 모두 보기", "aboutLicensesShowAllButtonLabel": "라이선스 모두 보기",
"policyPageTitle": "개인정보 보호정책",
"collectionPageTitle": "미디어", "collectionPageTitle": "미디어",
"collectionPickPageTitle": "항목 선택", "collectionPickPageTitle": "항목 선택",
"collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}", "collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}",
"collectionActionShowTitleSearch": "제목 필터 보기",
"collectionActionHideTitleSearch": "제목 필터 숨기기",
"collectionActionAddShortcut": "홈 화면에 추가", "collectionActionAddShortcut": "홈 화면에 추가",
"collectionActionCopy": "앨범으로 복사", "collectionActionCopy": "앨범으로 복사",
"collectionActionMove": "앨범으로 이동", "collectionActionMove": "앨범으로 이동",
"collectionActionRescan": "새로 분석", "collectionActionRescan": "새로 분석",
"collectionActionEdit": "편집",
"collectionSearchTitlesHintText": "제목 검색",
"collectionSortTitle": "정렬", "collectionSortTitle": "정렬",
"collectionSortDate": "날짜", "collectionSortDate": "날짜",
@ -253,9 +275,11 @@
"collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}", "collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}",
"collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}", "collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}",
"collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}", "collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}",
"collectionEditFailureFeedback": "{count, plural, other{항목 {count}개를 편집하지 못했습니다}}",
"collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}", "collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}",
"collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}", "collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}",
"collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}", "collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}",
"collectionEditSuccessFeedback": "{count, plural, other{항목 {count}개를 편집했습니다}}",
"collectionEmptyFavourites": "즐겨찾기가 없습니다", "collectionEmptyFavourites": "즐겨찾기가 없습니다",
"collectionEmptyVideos": "동영상이 없습니다", "collectionEmptyVideos": "동영상이 없습니다",
@ -340,8 +364,11 @@
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시", "settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
"settingsThumbnailShowVideoDuration": "동영상 길이 표시", "settingsThumbnailShowVideoDuration": "동영상 길이 표시",
"settingsCollectionSelectionQuickActionsTile": "항목 선택의 빠른 작업", "settingsCollectionQuickActionsTile": "빠른 작업",
"settingsCollectionSelectionQuickActionEditorTitle": "빠른 작업", "settingsCollectionQuickActionEditorTitle": "빠른 작업",
"settingsCollectionQuickActionTabBrowsing": "탐색 시",
"settingsCollectionQuickActionTabSelecting": "선택 시",
"settingsCollectionBrowsingQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 탐색할 때 표시될 버튼을 선택하세요.",
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어", "settingsSectionViewer": "뷰어",
@ -387,7 +414,9 @@
"settingsSubtitleThemeTextAlignmentRight": "오른쪽", "settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSectionPrivacy": "개인정보 보호", "settingsSectionPrivacy": "개인정보 보호",
"settingsEnableErrorReporting": "오류 보고서 보내기", "settingsAllowInstalledAppAccess": "설치된 앱의 목록 접근 허용",
"settingsAllowInstalledAppAccessSubtitle": "앨범 표시 개선을 위해",
"settingsAllowErrorReporting": "오류 보고서 보내기",
"settingsSaveSearchHistory": "검색기록", "settingsSaveSearchHistory": "검색기록",
"settingsHiddenFiltersTile": "숨겨진 필터", "settingsHiddenFiltersTile": "숨겨진 필터",
@ -421,15 +450,12 @@
"settingsUnitSystemTitle": "단위법", "settingsUnitSystemTitle": "단위법",
"statsPageTitle": "통계", "statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}",
"statsVideo": "{count, plural, other{동영상}}",
"statsWithGps": "{count, plural, other{{count}개 위치가 있음}}", "statsWithGps": "{count, plural, other{{count}개 위치가 있음}}",
"statsTopCountries": "국가 랭킹", "statsTopCountries": "국가 랭킹",
"statsTopPlaces": "장소 랭킹", "statsTopPlaces": "장소 랭킹",
"statsTopTags": "태그 랭킹", "statsTopTags": "태그 랭킹",
"viewerOpenPanoramaButtonLabel": "파노라마 열기", "viewerOpenPanoramaButtonLabel": "파노라마 열기",
"viewerOpenTooltip": "열기",
"viewerErrorUnknown": "아이구!", "viewerErrorUnknown": "아이구!",
"viewerErrorDoesNotExist": "파일이 존재하지 않습니다.", "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 'dart:isolate';
import 'package:aves/app_flavor.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/aves_app.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
void main() { void mainCommon(AppFlavor flavor) {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging // HttpClient.enableTimelineLogging = true; // enable network traffic logging
// debugPrintGestureArenaDiagnostics = true; // debugPrintGestureArenaDiagnostics = true;
@ -27,5 +28,5 @@ void main() {
reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last); reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
}).sendPort); }).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/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
enum ChipSetAction { enum ChipSetAction {
// general // general
@ -9,20 +9,50 @@ enum ChipSetAction {
select, select,
selectAll, selectAll,
selectNone, selectNone,
// browsing
search,
createAlbum, createAlbum,
// all or filter selection // browsing or selecting
map, map,
stats, stats,
// single/multiple filter selection // selecting (single/multiple filters)
delete, delete,
hide, hide,
pin, pin,
unpin, unpin,
// single filter selection // selecting (single filter)
rename, rename,
setCover, 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 { extension ExtraChipSetAction on ChipSetAction {
String getText(BuildContext context) { String getText(BuildContext context) {
switch (this) { switch (this) {
@ -37,13 +67,17 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.menuActionSelectAll; return context.l10n.menuActionSelectAll;
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
return context.l10n.menuActionSelectNone; 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: case ChipSetAction.map:
return context.l10n.menuActionMap; return context.l10n.menuActionMap;
case ChipSetAction.stats: case ChipSetAction.stats:
return context.l10n.menuActionStats; return context.l10n.menuActionStats;
case ChipSetAction.createAlbum: // selecting (single/multiple filters)
return context.l10n.chipActionCreateAlbum;
// single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:
return context.l10n.chipActionDelete; return context.l10n.chipActionDelete;
case ChipSetAction.hide: case ChipSetAction.hide:
@ -52,7 +86,7 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.chipActionPin; return context.l10n.chipActionPin;
case ChipSetAction.unpin: case ChipSetAction.unpin:
return context.l10n.chipActionUnpin; return context.l10n.chipActionUnpin;
// single filter // selecting (single filter)
case ChipSetAction.rename: case ChipSetAction.rename:
return context.l10n.chipActionRename; return context.l10n.chipActionRename;
case ChipSetAction.setCover: case ChipSetAction.setCover:
@ -77,13 +111,17 @@ extension ExtraChipSetAction on ChipSetAction {
return AIcons.selected; return AIcons.selected;
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
return AIcons.unselected; return AIcons.unselected;
// browsing
case ChipSetAction.search:
return AIcons.search;
case ChipSetAction.createAlbum:
return AIcons.add;
// browsing or selecting
case ChipSetAction.map: case ChipSetAction.map:
return AIcons.map; return AIcons.map;
case ChipSetAction.stats: case ChipSetAction.stats:
return AIcons.stats; return AIcons.stats;
case ChipSetAction.createAlbum: // selecting (single/multiple filters)
return AIcons.add;
// single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:
return AIcons.delete; return AIcons.delete;
case ChipSetAction.hide: case ChipSetAction.hide:
@ -92,7 +130,7 @@ extension ExtraChipSetAction on ChipSetAction {
return AIcons.pin; return AIcons.pin;
case ChipSetAction.unpin: case ChipSetAction.unpin:
return AIcons.unpin; return AIcons.unpin;
// single filter // selecting (single filter)
case ChipSetAction.rename: case ChipSetAction.rename:
return AIcons.rename; return AIcons.rename;
case ChipSetAction.setCover: case ChipSetAction.setCover:

View file

@ -1,6 +1,6 @@
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
enum EntrySetAction { enum EntrySetAction {
// general // general
@ -9,20 +9,43 @@ enum EntrySetAction {
select, select,
selectAll, selectAll,
selectNone, selectNone,
// all // browsing
searchCollection,
toggleTitleSearch,
addShortcut, addShortcut,
// all or entry selection // browsing or selecting
map, map,
stats, stats,
// entry selection // selecting
share, share,
delete, delete,
copy, copy,
move, move,
rescan, rescan,
rotateCCW,
rotateCW,
flip,
editDate,
removeMetadata,
} }
class EntrySetActions { 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 = [ static const selection = [
EntrySetAction.share, EntrySetAction.share,
EntrySetAction.delete, EntrySetAction.delete,
@ -31,6 +54,7 @@ class EntrySetActions {
EntrySetAction.rescan, EntrySetAction.rescan,
EntrySetAction.map, EntrySetAction.map,
EntrySetAction.stats, EntrySetAction.stats,
// editing actions are in their subsection
]; ];
} }
@ -48,15 +72,20 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.menuActionSelectAll; return context.l10n.menuActionSelectAll;
case EntrySetAction.selectNone: case EntrySetAction.selectNone:
return context.l10n.menuActionSelectNone; 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: case EntrySetAction.addShortcut:
return context.l10n.collectionActionAddShortcut; return context.l10n.collectionActionAddShortcut;
// all or entry selection // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
return context.l10n.menuActionMap; return context.l10n.menuActionMap;
case EntrySetAction.stats: case EntrySetAction.stats:
return context.l10n.menuActionStats; return context.l10n.menuActionStats;
// entry selection // selecting
case EntrySetAction.share: case EntrySetAction.share:
return context.l10n.entryActionShare; return context.l10n.entryActionShare;
case EntrySetAction.delete: case EntrySetAction.delete:
@ -67,6 +96,16 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionMove; return context.l10n.collectionActionMove;
case EntrySetAction.rescan: case EntrySetAction.rescan:
return context.l10n.collectionActionRescan; 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; return AIcons.selected;
case EntrySetAction.selectNone: case EntrySetAction.selectNone:
return AIcons.unselected; 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: case EntrySetAction.addShortcut:
return AIcons.addShortcut; return AIcons.addShortcut;
// all or entry selection // browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
return AIcons.map; return AIcons.map;
case EntrySetAction.stats: case EntrySetAction.stats:
return AIcons.stats; return AIcons.stats;
// entry selection // selecting
case EntrySetAction.share: case EntrySetAction.share:
return AIcons.share; return AIcons.share;
case EntrySetAction.delete: case EntrySetAction.delete:
@ -106,6 +150,16 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.move; return AIcons.move;
case EntrySetAction.rescan: case EntrySetAction.rescan:
return AIcons.refresh; 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 { enum VideoAction {
captureFrame, captureFrame,
playOutside,
replay10, replay10,
skip10, skip10,
selectStreams, selectStreams,
@ -21,6 +22,7 @@ class VideoActions {
VideoAction.selectStreams, VideoAction.selectStreams,
VideoAction.replay10, VideoAction.replay10,
VideoAction.skip10, VideoAction.skip10,
VideoAction.playOutside,
VideoAction.settings, VideoAction.settings,
]; ];
} }
@ -30,6 +32,8 @@ extension ExtraVideoAction on VideoAction {
switch (this) { switch (this) {
case VideoAction.captureFrame: case VideoAction.captureFrame:
return context.l10n.videoActionCaptureFrame; return context.l10n.videoActionCaptureFrame;
case VideoAction.playOutside:
return context.l10n.entryActionOpen;
case VideoAction.replay10: case VideoAction.replay10:
return context.l10n.videoActionReplay10; return context.l10n.videoActionReplay10;
case VideoAction.skip10: case VideoAction.skip10:
@ -54,6 +58,8 @@ extension ExtraVideoAction on VideoAction {
switch (this) { switch (this) {
case VideoAction.captureFrame: case VideoAction.captureFrame:
return AIcons.captureFrame; return AIcons.captureFrame;
case VideoAction.playOutside:
return AIcons.openOutside;
case VideoAction.replay10: case VideoAction.replay10:
return AIcons.replay10; return AIcons.replay10;
case VideoAction.skip10: case VideoAction.skip10:

View file

@ -15,7 +15,7 @@ class Covers with ChangeNotifier {
Covers._private(); Covers._private();
Future<void> init() async { Future<void> init() async {
_rows = await metadataDb.loadCovers(); _rows = await metadataDb.loadAllCovers();
} }
int get count => _rows.length; 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/services/metadata/svg_metadata_service.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -548,14 +549,6 @@ class AvesEntry {
}.whereNotNull().where((v) => v.isNotEmpty).join(', '); }.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 { Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
final oldDateModifiedSecs = this.dateModifiedSecs; final oldDateModifiedSecs = this.dateModifiedSecs;
final oldRotationDegrees = this.rotationDegrees; final oldRotationDegrees = this.rotationDegrees;
@ -635,6 +628,16 @@ class AvesEntry {
} }
Future<bool> editDate(DateModifier modifier) async { 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); final newFields = await metadataEditService.editDate(this, modifier);
return newFields.isNotEmpty; return newFields.isNotEmpty;
} }

View file

@ -12,7 +12,7 @@ class Favourites with ChangeNotifier {
Favourites._private(); Favourites._private();
Future<void> init() async { Future<void> init() async {
_rows = await metadataDb.loadFavourites(); _rows = await metadataDb.loadAllFavourites();
} }
int get count => _rows.length; 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/model/settings/settings.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/geo_utils.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/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -37,8 +39,9 @@ class CoordinateFilter extends CollectionFilter {
@override @override
EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng); 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( String s(LatLng latLng) => format.format(
l10n,
latLng, latLng,
minuteSecondPadding: minuteSecondPadding, minuteSecondPadding: minuteSecondPadding,
dmsSecondDecimals: 0, dmsSecondDecimals: 0,
@ -47,10 +50,10 @@ class CoordinateFilter extends CollectionFilter {
} }
@override @override
String get universalLabel => _formatBounds(CoordinateFormat.decimal); String get universalLabel => _formatBounds(lookupAppLocalizations(AppLocalizations.supportedLocales.first), CoordinateFormat.decimal);
@override @override
String getLabel(BuildContext context) => _formatBounds(context.read<Settings>().coordinateFormat); String getLabel(BuildContext context) => _formatBounds(context.l10n, context.read<Settings>().coordinateFormat);
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size);

View file

@ -31,6 +31,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
]; ];
static CollectionFilter? fromJson(String jsonString) { static CollectionFilter? fromJson(String jsonString) {
try {
final jsonMap = jsonDecode(jsonString); final jsonMap = jsonDecode(jsonString);
if (jsonMap is Map<String, dynamic>) { if (jsonMap is Map<String, dynamic>) {
final type = jsonMap['type']; final type = jsonMap['type'];
@ -55,6 +56,9 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return TypeFilter.fromMap(jsonMap); 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'); debugPrint('failed to parse filter from json=$jsonString');
return null; return null;
} }

View file

@ -1,3 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
@ -11,15 +12,15 @@ class QueryFilter extends CollectionFilter {
static final RegExp exactRegex = RegExp('^"(.*)"\$'); static final RegExp exactRegex = RegExp('^"(.*)"\$');
final String query; final String query;
final bool colorful; final bool colorful, live;
late final EntryFilter _test; late final EntryFilter _test;
@override @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(); var upQuery = query.toUpperCase();
if (upQuery.startsWith('ID=')) { if (upQuery.startsWith('ID:')) {
final id = int.tryParse(upQuery.substring(3)); final id = int.tryParse(upQuery.substring(3));
_test = (entry) => entry.contentId == id; _test = (entry) => entry.contentId == id;
return; return;
@ -37,7 +38,9 @@ class QueryFilter extends CollectionFilter {
upQuery = matches.first.group(1)!; 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) QueryFilter.fromMap(Map<String, dynamic> json)

View file

@ -8,6 +8,7 @@ enum MetadataField {
enum DateEditAction { enum DateEditAction {
set, set,
shift, shift,
extractFromTitle,
clear, 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/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata_db_upgrade.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:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -25,7 +26,7 @@ abstract class MetadataDb {
Future<void> clearEntries(); Future<void> clearEntries();
Future<Set<AvesEntry>> loadEntries(); Future<Set<AvesEntry>> loadAllEntries();
Future<void> saveEntries(Iterable<AvesEntry> entries); Future<void> saveEntries(Iterable<AvesEntry> entries);
@ -43,7 +44,7 @@ abstract class MetadataDb {
Future<void> clearMetadataEntries(); Future<void> clearMetadataEntries();
Future<List<CatalogMetadata>> loadMetadataEntries(); Future<List<CatalogMetadata>> loadAllMetadataEntries();
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries); Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
@ -53,7 +54,7 @@ abstract class MetadataDb {
Future<void> clearAddresses(); Future<void> clearAddresses();
Future<List<AddressDetails>> loadAddresses(); Future<List<AddressDetails>> loadAllAddresses();
Future<void> saveAddresses(Set<AddressDetails> addresses); Future<void> saveAddresses(Set<AddressDetails> addresses);
@ -63,7 +64,7 @@ abstract class MetadataDb {
Future<void> clearFavourites(); Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadFavourites(); Future<Set<FavouriteRow>> loadAllFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows); Future<void> addFavourites(Iterable<FavouriteRow> rows);
@ -75,13 +76,27 @@ abstract class MetadataDb {
Future<void> clearCovers(); Future<void> clearCovers();
Future<Set<CoverRow>> loadCovers(); Future<Set<CoverRow>> loadAllCovers();
Future<void> addCovers(Iterable<CoverRow> rows); Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row); Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Set<CollectionFilter> filters); 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 { class SqfliteMetadataDb implements MetadataDb {
@ -95,6 +110,7 @@ class SqfliteMetadataDb implements MetadataDb {
static const addressTable = 'address'; static const addressTable = 'address';
static const favouriteTable = 'favourites'; static const favouriteTable = 'favourites';
static const coverTable = 'covers'; static const coverTable = 'covers';
static const videoPlaybackTable = 'videoPlayback';
@override @override
Future<void> init() async { Future<void> init() async {
@ -146,9 +162,13 @@ class SqfliteMetadataDb implements MetadataDb {
'filter TEXT PRIMARY KEY' 'filter TEXT PRIMARY KEY'
', contentId INTEGER' ', contentId INTEGER'
')'); ')');
await db.execute('CREATE TABLE $videoPlaybackTable('
'contentId INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
}, },
onUpgrade: MetadataDbUpgrader.upgradeDb, onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 4, version: 5,
); );
} }
@ -183,6 +203,7 @@ class SqfliteMetadataDb implements MetadataDb {
if (!metadataOnly) { if (!metadataOnly) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs); batch.delete(coverTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
@ -194,11 +215,11 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearEntries() async { Future<void> clearEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(entryTable, where: '1'); final count = await db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count entries'); debugPrint('$runtimeType clearEntries deleted $count rows');
} }
@override @override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadAllEntries() async {
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
@ -252,7 +273,7 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearDates() async { Future<void> clearDates() async {
final db = await _database; final db = await _database;
final count = await db.delete(dateTakenTable, where: '1'); final count = await db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count entries'); debugPrint('$runtimeType clearDates deleted $count rows');
} }
@override @override
@ -269,11 +290,11 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearMetadataEntries() async { Future<void> clearMetadataEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(metadataTable, where: '1'); final count = await db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
} }
@override @override
Future<List<CatalogMetadata>> loadMetadataEntries() async { Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
final db = await _database; final db = await _database;
final maps = await db.query(metadataTable); final maps = await db.query(metadataTable);
final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList(); final metadataEntries = maps.map((map) => CatalogMetadata.fromMap(map)).toList();
@ -330,11 +351,11 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearAddresses() async { Future<void> clearAddresses() async {
final db = await _database; final db = await _database;
final count = await db.delete(addressTable, where: '1'); final count = await db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count entries'); debugPrint('$runtimeType clearAddresses deleted $count rows');
} }
@override @override
Future<List<AddressDetails>> loadAddresses() async { Future<List<AddressDetails>> loadAllAddresses() async {
final db = await _database; final db = await _database;
final maps = await db.query(addressTable); final maps = await db.query(addressTable);
final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList(); final addresses = maps.map((map) => AddressDetails.fromMap(map)).toList();
@ -376,11 +397,11 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearFavourites() async { Future<void> clearFavourites() async {
final db = await _database; final db = await _database;
final count = await db.delete(favouriteTable, where: '1'); final count = await db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count entries'); debugPrint('$runtimeType clearFavourites deleted $count rows');
} }
@override @override
Future<Set<FavouriteRow>> loadFavourites() async { Future<Set<FavouriteRow>> loadAllFavourites() async {
final db = await _database; final db = await _database;
final maps = await db.query(favouriteTable); final maps = await db.query(favouriteTable);
final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet(); final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
@ -432,11 +453,11 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> clearCovers() async { Future<void> clearCovers() async {
final db = await _database; final db = await _database;
final count = await db.delete(coverTable, where: '1'); final count = await db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count entries'); debugPrint('$runtimeType clearCovers deleted $count rows');
} }
@override @override
Future<Set<CoverRow>> loadCovers() async { Future<Set<CoverRow>> loadAllCovers() async {
final db = await _database; final db = await _database;
final maps = await db.query(coverTable); final maps = await db.query(coverTable);
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet(); final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
@ -446,6 +467,7 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> addCovers(Iterable<CoverRow> rows) async { Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows.isEmpty) return; if (rows.isEmpty) return;
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
rows.forEach((row) => _batchInsertCover(batch, row)); 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()])); filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
await batch.commit(noResult: true); 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 entryTable = SqfliteMetadataDb.entryTable;
static const metadataTable = SqfliteMetadataDb.metadataTable; static const metadataTable = SqfliteMetadataDb.metadataTable;
static const coverTable = SqfliteMetadataDb.coverTable; static const coverTable = SqfliteMetadataDb.coverTable;
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices // on SQLite <3.25.0, bundled on older Android devices
@ -21,6 +22,9 @@ class MetadataDbUpgrader {
case 3: case 3:
await _upgradeFrom3(db); await _upgradeFrom3(db);
break; break;
case 4:
await _upgradeFrom4(db);
break;
} }
oldVersion++; oldVersion++;
} }
@ -109,4 +113,12 @@ class MetadataDbUpgrader {
', contentId INTEGER' ', 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/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'enums.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) { switch (this) {
case CoordinateFormat.dms: case CoordinateFormat.dms:
return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', '); return toDMS(l10n, latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); 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 // app
static const hasAcceptedTerms = false; static const hasAcceptedTerms = false;
static const canUseAnalysisService = true; static const canUseAnalysisService = true;
static const isErrorReportingEnabled = false; static const isInstalledAppAccessAllowed = false;
static const isErrorReportingAllowed = false;
static const mustBackTwiceToExit = true; static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly; static const keepScreenOn = KeepScreenOn.viewerOnly;
static const homePage = HomePageSetting.collection; static const homePage = HomePageSetting.collection;
@ -34,6 +35,9 @@ class SettingsDefaults {
// collection // collection
static const collectionSectionFactor = EntryGroupFactor.month; static const collectionSectionFactor = EntryGroupFactor.month;
static const collectionSortFactor = EntrySortFactor.date; static const collectionSortFactor = EntrySortFactor.date;
static const collectionBrowsingQuickActions = [
EntrySetAction.searchCollection,
];
static const collectionSelectionQuickActions = [ static const collectionSelectionQuickActions = [
EntrySetAction.share, EntrySetAction.share,
EntrySetAction.delete, EntrySetAction.delete,

View file

@ -41,7 +41,8 @@ class Settings extends ChangeNotifier {
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const canUseAnalysisServiceKey = 'can_use_analysis_service'; 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 localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on'; static const keepScreenOnKey = 'keep_screen_on';
@ -57,6 +58,7 @@ class Settings extends ChangeNotifier {
// collection // collection
static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionBrowsingQuickActionsKey = 'collection_browsing_quick_actions';
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions'; static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
static const showThumbnailLocationKey = 'show_thumbnail_location'; static const showThumbnailLocationKey = 'show_thumbnail_location';
static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo'; static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo';
@ -173,9 +175,14 @@ class Settings extends ChangeNotifier {
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue); 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 = '-'; static const localeSeparator = '-';
@ -265,6 +272,10 @@ class Settings extends ChangeNotifier {
set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString()); 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); List<EntrySetAction> get collectionSelectionQuickActions => getEnumListOrDefault(collectionSelectionQuickActionsKey, SettingsDefaults.collectionSelectionQuickActions, EntrySetAction.values);
set collectionSelectionQuickActions(List<EntrySetAction> newValue) => setAndNotify(collectionSelectionQuickActionsKey, newValue.map((v) => v.toString()).toList()); 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'); debugPrint('failed to import key=$key, value=$value is not a double');
} }
break; break;
case isErrorReportingEnabledKey: case isInstalledAppAccessAllowedKey:
case isErrorReportingAllowedKey:
case mustBackTwiceToExitKey: case mustBackTwiceToExitKey:
case showThumbnailLocationKey: case showThumbnailLocationKey:
case showThumbnailMotionPhotoKey: case showThumbnailMotionPhotoKey:
@ -613,6 +625,7 @@ class Settings extends ChangeNotifier {
case drawerPageBookmarksKey: case drawerPageBookmarksKey:
case pinnedFiltersKey: case pinnedFiltersKey:
case hiddenFiltersKey: case hiddenFiltersKey:
case collectionBrowsingQuickActionsKey:
case collectionSelectionQuickActionsKey: case collectionSelectionQuickActionsKey:
case viewerQuickActionsKey: case viewerQuickActionsKey:
case videoQuickActionsKey: 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/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
@ -36,7 +37,7 @@ class CollectionLens with ChangeNotifier {
CollectionLens({ CollectionLens({
required this.source, required this.source,
Iterable<CollectionFilter?>? filters, Set<CollectionFilter?>? filters,
this.id, this.id,
this.listenToSource = true, this.listenToSource = true,
this.fixedSelection, this.fixedSelection,
@ -126,6 +127,14 @@ class CollectionLens with ChangeNotifier {
_onFilterChanged(); _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() { void _onFilterChanged() {
_refresh(); _refresh();
filterChangeNotifier.notifyListeners(); 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(); final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
await favourites.remove(entries); await favourites.remove(entries);
await covers.removeEntries(entries); await covers.removeEntries(entries);
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
entries.forEach((v) => _entryById.remove(v.contentId)); entries.forEach((v) => _entryById.remove(v.contentId));
_rawEntries.removeAll(entries); _rawEntries.removeAll(entries);
@ -157,6 +158,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
await metadataDb.updateAddressId(oldContentId, entry.addressDetails); await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.moveEntry(oldContentId, entry); await favourites.moveEntry(oldContentId, entry);
await covers.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([]); List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async { Future<void> loadAddresses() async {
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAllAddresses();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
onAddressMetadataChanged(); onAddressMetadataChanged();

View file

@ -51,7 +51,7 @@ class MediaStoreSource extends CollectionSource {
clearEntries(); clearEntries();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); 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'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!))); final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();

View file

@ -15,7 +15,7 @@ mixin TagMixin on SourceBase {
List<String> sortedTags = List.unmodifiable([]); List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async { Future<void> loadCatalogMetadata() async {
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadAllMetadataEntries();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
onCatalogMetadataChanged(); onCatalogMetadataChanged();

View file

@ -22,6 +22,7 @@ import 'package:flutter/foundation.dart';
class VideoMetadataFormatter { class VideoMetadataFormatter {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); 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 _durationPattern = RegExp(r'(\d+):(\d+):(\d+)(.\d+)');
static final _locationPattern = RegExp(r'([+-][.0-9]+)'); static final _locationPattern = RegExp(r'([+-][.0-9]+)');
static final Map<String, String> _codecNames = { static final Map<String, String> _codecNames = {
@ -80,19 +81,16 @@ class VideoMetadataFormatter {
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async { static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
final mediaInfo = await getVideoMetadata(entry); final mediaInfo = await getVideoMetadata(entry);
int? dateMillis;
bool isDefined(dynamic value) => value is String && value != '0'; bool isDefined(dynamic value) => value is String && value != '0';
var dateString = mediaInfo[Keys.date]; var dateString = mediaInfo[Keys.date];
if (!isDefined(dateString)) { if (!isDefined(dateString)) {
dateString = mediaInfo[Keys.creationTime]; dateString = mediaInfo[Keys.creationTime];
} }
int? dateMillis;
if (isDefined(dateString)) { if (isDefined(dateString)) {
final date = DateTime.tryParse(dateString); dateMillis = parseVideoDate(dateString);
if (date != null) { if (dateMillis == null) {
dateMillis = date.millisecondsSinceEpoch;
} else {
await reportService.recordError('getCatalogMetadata failed to parse date=$dateString for mimeType=${entry.mimeType} entry=$entry', 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; 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' // pattern to extract optional language code suffix, e.g. 'location-eng'
static final keyWithLanguagePattern = RegExp(r'^(.*)-([a-z]{3})$'); 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 aviVnd = 'video/vnd.avi';
static const mkv = 'video/x-matroska'; static const mkv = 'video/x-matroska';
static const mov = 'video/quicktime'; 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 mp4 = 'video/mp4';
static const ogv = 'video/ogg'; static const ogv = 'video/ogg';
static const webm = 'video/webm'; static const webm = 'video/webm';
@ -67,7 +68,7 @@ class MimeTypes {
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg}; 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}; 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/media/media_store_service.dart';
import 'package:aves/services/metadata/metadata_edit_service.dart'; import 'package:aves/services/metadata/metadata_edit_service.dart';
import 'package:aves/services/metadata/metadata_fetch_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/storage_service.dart';
import 'package:aves/services/window_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:get_it/get_it.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -42,7 +43,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService()); getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataEditService>(() => PlatformMetadataEditService()); getIt.registerLazySingleton<MetadataEditService>(() => PlatformMetadataEditService());
getIt.registerLazySingleton<MetadataFetchService>(() => PlatformMetadataFetchService()); getIt.registerLazySingleton<MetadataFetchService>(() => PlatformMetadataFetchService());
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService()); getIt.registerLazySingleton<ReportService>(() => PlatformReportService());
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService()); getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService()); getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());
} }

View file

@ -47,6 +47,9 @@ Future<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
if (args is Map) { if (args is Map) {
final query = args['query']; final query = args['query'];
final locale = args['locale']; final locale = args['locale'];
final use24hour = args['use24hour'];
debugPrint('getSuggestions query=$query, locale=$locale use24hour=$use24hour');
if (query is String && locale is String) { if (query is String && locale is String) {
final entries = await metadataDb.searchEntries(query, limit: 9); final entries = await metadataDb.searchEntries(query, limit: 9);
suggestions.addAll(entries.map((entry) { suggestions.addAll(entries.map((entry) {
@ -55,7 +58,7 @@ Future<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
'data': entry.uri, 'data': entry.uri,
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
'title': entry.bestTitle, 'title': entry.bestTitle,
'subtitle': date != null ? formatDateTime(date, locale) : null, 'subtitle': date != null ? formatDateTime(date, locale, use24hour) : null,
'iconUri': entry.uri, 'iconUri': entry.uri,
}; };
})); }));

View file

@ -44,8 +44,10 @@ class PlatformMetadataEditService implements MetadataEditService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return {}; return {};
} }
@ -58,8 +60,10 @@ class PlatformMetadataEditService implements MetadataEditService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return {}; return {};
} }
@ -74,8 +78,10 @@ class PlatformMetadataEditService implements MetadataEditService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return {}; return {};
} }
@ -88,8 +94,10 @@ class PlatformMetadataEditService implements MetadataEditService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return {}; return {};
} }

View file

@ -72,8 +72,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
result['contentId'] = entry.contentId; result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result); return CatalogMetadata.fromMap(result);
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return null; return null;
} }
@ -98,8 +100,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}) as Map; }) as Map;
return OverlayMetadata.fromMap(result); return OverlayMetadata.fromMap(result);
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return null; return null;
} }
@ -140,8 +144,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}) as Map; }) as Map;
return PanoramaInfo.fromMap(result); return PanoramaInfo.fromMap(result);
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return null; return null;
} }
@ -173,8 +179,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
'prop': prop, 'prop': prop,
}); });
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return null; return null;
} }
} }

View file

@ -5,12 +5,12 @@ import 'package:provider/provider.dart';
class Durations { class Durations {
// Flutter animations (with margin) // 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` // 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 pageTransitionAnimation = Duration(milliseconds: 300 + 20); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` static const dialogTransitionAnimation = Duration(milliseconds: 150 + 20); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState` static const drawerTransitionAnimation = Duration(milliseconds: 246 + 20); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 20); // ref `_kToggleDuration` used in `ToggleableStateMixin`
// common animations // common animations
static const sweeperOpacityAnimation = Duration(milliseconds: 150); 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 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) { String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0'); 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 flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; 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 geoBounds = Icons.public_outlined;
static const IconData goUp = Icons.arrow_upward_outlined; static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;

View file

@ -39,12 +39,19 @@ class AndroidFileUtils {
Future<void> initAppNames() async { Future<void> initAppNames() async {
if (_packages.isEmpty) { if (_packages.isEmpty) {
debugPrint('Access installed app inventory');
_packages = await androidAppService.getPackages(); _packages = await androidAppService.getPackages();
_potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
areAppNamesReadyNotifier.value = true; 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 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'); bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots');

View file

@ -1,5 +1,6 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -86,7 +87,7 @@ class Constants {
), ),
]; ];
static const List<Dependency> flutterPlugins = [ static const List<Dependency> _flutterPluginsCommon = [
Dependency( Dependency(
name: 'Connectivity Plus', name: 'Connectivity Plus',
license: 'BSD 3-Clause', 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', 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', 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( Dependency(
name: 'fijkplayer (Aves fork)', name: 'fijkplayer (Aves fork)',
license: 'MIT', 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 = [ static const List<Dependency> flutterPackages = [
Dependency( Dependency(
name: 'Charts', name: 'Charts',

View file

@ -5,7 +5,7 @@ import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class GeoUtils { class GeoUtils {
static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) { static String decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
List<int> _split(final double value) { List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value // NumberFormat is necessary to create digit after comma if the value
// has no decimal point (only necessary for browser) // has no decimal point (only necessary for browser)
@ -32,16 +32,6 @@ class GeoUtils {
return '$deg° $minText $secText'; 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) { static LatLng getLatLngCenter(List<LatLng> points) {
double x = 0; double x = 0;
double y = 0; double y = 0;

View file

@ -13,3 +13,58 @@ extension ExtraDateTime on DateTime {
bool get isThisYear => isAtSameYearAs(DateTime.now()); 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/theme/icons.dart';
import 'package:aves/utils/constants.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/basic/link_chip.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart';
@ -66,16 +67,18 @@ class _AppReferenceState extends State<AppReference> {
} }
Widget _buildLinks() { Widget _buildLinks() {
final l10n = context.l10n;
return Wrap( return Wrap(
crossAxisAlignment: WrapCrossAlignment.center, alignment: WrapAlignment.center,
spacing: 16, spacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
LinkChip( LinkChip(
leading: const Icon( leading: const Icon(
AIcons.github, AIcons.github,
size: 24, size: 24,
), ),
text: context.l10n.aboutLinkSources, text: l10n.aboutLinkSources,
url: Constants.avesGithub, url: Constants.avesGithub,
), ),
LinkChip( LinkChip(
@ -83,10 +86,28 @@ class _AppReferenceState extends State<AppReference> {
AIcons.legal, AIcons.legal,
size: 22, size: 22,
), ),
text: context.l10n.aboutLinkLicense, text: l10n.aboutLinkLicense,
url: '${Constants.avesGithub}/blob/main/LICENSE', 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:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/app_flavor.dart';
import 'package:aves/flutter_version.dart'; import 'package:aves/flutter_version.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -33,7 +34,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_infoLoader = _getInfo(); _infoLoader = _getInfo(context);
} }
@override @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 packageInfo = await PackageInfo.fromPlatform();
final androidInfo = await DeviceInfoPlugin().androidInfo; final androidInfo = await DeviceInfoPlugin().androidInfo;
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;
final flavor = context.read<AppFlavor>().toString().split('.')[1];
return [ return [
'Aves version: ${packageInfo.version} (Build ${packageInfo.buildNumber})', 'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})',
'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})', 'Flutter version: ${version['frameworkVersion']} (Channel ${version['channel']})',
'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})', 'Android version: ${androidInfo.version.release} (SDK ${androidInfo.version.sdkInt})',
'Android build: ${androidInfo.display}',
'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}', 'Google Play services: ${hasPlayServices ? 'ready' : 'not available'}',
].join('\n'); ].join('\n');

View file

@ -8,6 +8,10 @@ import 'package:flutter/material.dart';
class AboutCredits extends StatelessWidget { class AboutCredits extends StatelessWidget {
const AboutCredits({Key? key}) : super(key: key); const AboutCredits({Key? key}) : super(key: key);
static const translations = [
'Русский: D3ZOXY',
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -39,6 +43,14 @@ class AboutCredits extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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/ref/brand_colors.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/link_chip.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:aves/widgets/common/identity/buttons.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Licenses extends StatefulWidget { class Licenses extends StatefulWidget {
const Licenses({Key? key}) : super(key: key); const Licenses({Key? key}) : super(key: key);
@ -22,7 +24,7 @@ class _LicensesState extends State<Licenses> {
void initState() { void initState() {
super.initState(); super.initState();
_platform = List<Dependency>.from(Constants.androidDependencies); _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); _flutterPackages = List<Dependency>.from(Constants.flutterPackages);
_dartPackages = List<Dependency>.from(Constants.dartPackages); _dartPackages = List<Dependency>.from(Constants.dartPackages);
_sortPackages(); _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 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/accessibility_animations.dart'; import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/screen_on.dart';
@ -11,6 +12,7 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
@ -29,7 +31,12 @@ import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class AvesApp extends StatefulWidget { 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 @override
_AvesAppState createState() => _AvesAppState(); _AvesAppState createState() => _AvesAppState();
@ -68,7 +75,9 @@ class _AvesAppState extends State<AvesApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// place the settings provider above `MaterialApp` // place the settings provider above `MaterialApp`
// so it can be used during navigation transitions // so it can be used during navigation transitions
return ChangeNotifierProvider<Settings>.value( return Provider<AppFlavor>.value(
value: widget.flavor,
child: ChangeNotifierProvider<Settings>.value(
value: settings, value: settings,
child: ListenableProvider<ValueNotifier<AppMode>>.value( child: ListenableProvider<ValueNotifier<AppMode>>.value(
value: appModeNotifier, value: appModeNotifier,
@ -127,6 +136,7 @@ class _AvesAppState extends State<AvesApp> {
), ),
), ),
), ),
),
); );
} }
@ -159,12 +169,23 @@ class _AvesAppState extends State<AvesApp> {
); );
settings.keepScreenOn.apply(); settings.keepScreenOn.apply();
// installed app access
settings.updateStream.where((key) => key == Settings.isInstalledAppAccessAllowedKey).listen(
(_) {
if (settings.isInstalledAppAccessAllowed) {
androidFileUtils.initAppNames();
} else {
androidFileUtils.resetAppNames();
}
},
);
// error reporting // error reporting
await reportService.init(); await reportService.init();
settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen( settings.updateStream.where((key) => key == Settings.isErrorReportingAllowedKey).listen(
(_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled), (_) => reportService.setCollectionEnabled(settings.isErrorReportingAllowed),
); );
await reportService.setCollectionEnabled(settings.isErrorReportingEnabled); await reportService.setCollectionEnabled(settings.isErrorReportingAllowed);
FlutterError.onError = reportService.recordFlutterError; FlutterError.onError = reportService.recordFlutterError;
final now = DateTime.now(); final now = DateTime.now();

View file

@ -3,7 +3,8 @@ import 'dart:async';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/entry.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/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.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/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.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_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.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/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -42,20 +43,27 @@ class CollectionAppBar extends StatefulWidget {
} }
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin { class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final List<StreamSubscription> _subscriptions = [];
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader; late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false); final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
final FocusNode _queryBarFocusNode = FocusNode();
late final Listenable _queryFocusRequestNotifier;
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
CollectionSource get source => collection.source; CollectionSource get source => collection.source;
bool get hasFilters => collection.filters.isNotEmpty; bool get showFilterBar => collection.filters.any((v) => !(v is QueryFilter && v.live));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final query = context.read<Query>();
_subscriptions.add(query.enabledStream.listen((e) => _updateAppBarHeight()));
_queryFocusRequestNotifier = query.focusRequestNotifier;
_queryFocusRequestNotifier.addListener(_onQueryFocusRequest);
_browseToSelectAnimation = AnimationController( _browseToSelectAnimation = AnimationController(
duration: context.read<DurationsData>().iconAnimation, duration: context.read<DurationsData>().iconAnimation,
vsync: this, vsync: this,
@ -76,8 +84,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void dispose() { void dispose() {
_unregisterWidget(widget); _unregisterWidget(widget);
_queryFocusRequestNotifier.removeListener(_onQueryFocusRequest);
_isSelectingNotifier.removeListener(_onActivityChange); _isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose(); _browseToSelectAnimation.dispose();
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose(); super.dispose();
} }
@ -92,25 +104,49 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value; final appMode = context.watch<ValueNotifier<AppMode>>().value;
return Selector<Selection<AvesEntry>, bool>( return FutureBuilder<bool>(
selector: (context, selection) => selection.isSelecting, future: _canAddShortcutsLoader,
builder: (context, isSelecting, child) { 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; _isSelectingNotifier.value = isSelecting;
return AnimatedBuilder( return AnimatedBuilder(
animation: collection.filterChangeNotifier, animation: collection.filterChangeNotifier,
builder: (context, child) { builder: (context, child) {
final removableFilters = appMode != AppMode.pickInternal; final removableFilters = appMode != AppMode.pickInternal;
return Selector<Query, bool>(
selector: (context, query) => query.enabled,
builder: (context, queryEnabled, child) {
return SliverAppBar( return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null, leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting), title: _buildAppBarTitle(isSelecting),
actions: _buildActions(isSelecting), actions: _buildActions(
bottom: hasFilters isSelecting: isSelecting,
? FilterBar( selectedItemCount: selectedItemCount,
filters: collection.filters, 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, removable: removableFilters,
onTap: removableFilters ? collection.removeFilter : null, onTap: removableFilters ? collection.removeFilter : null,
),
if (queryEnabled)
EntryQueryBar(
queryNotifier: context.select<Query, ValueNotifier<String>>((query) => query.queryNotifier),
focusNode: _queryBarFocusNode,
) )
: null, ],
),
),
titleSpacing: 0, titleSpacing: 0,
floating: true, floating: true,
); );
@ -118,6 +154,15 @@ 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) { Widget _buildAppBarLeading(bool isSelecting) {
@ -143,14 +188,16 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
Widget? _buildAppBarTitle(bool isSelecting) { Widget? _buildAppBarTitle(bool isSelecting) {
final l10n = context.l10n;
if (isSelecting) { if (isSelecting) {
return Selector<Selection<AvesEntry>, int>( return Selector<Selection<AvesEntry>, int>(
selector: (context, selection) => selection.selectedItems.length, selector: (context, selection) => selection.selectedItems.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)), builder: (context, count, child) => Text(l10n.collectionSelectionPageTitle(count)),
); );
} else { } else {
final appMode = context.watch<ValueNotifier<AppMode>>().value; 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) { if (appMode == AppMode.main) {
title = SourceStateAwareAppBarTitle( title = SourceStateAwareAppBarTitle(
title: title, 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 appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
action,
appMode: appMode,
isSelecting: isSelecting,
supportShortcuts: supportShortcuts,
sortFactor: collection.sortFactor,
itemCount: collection.entryCount,
selectedItemCount: selectedItemCount,
);
bool canApply(EntrySetAction action) => _actionDelegate.canApply(
action,
isSelecting: isSelecting,
itemCount: collection.entryCount,
selectedItemCount: selectedItemCount,
);
final canApplyEditActions = selectedItemCount > 0;
final browsingQuickActions = settings.collectionBrowsingQuickActions;
final selectionQuickActions = settings.collectionSelectionQuickActions; final selectionQuickActions = settings.collectionSelectionQuickActions;
final quickActionButtons = (isSelecting ? selectionQuickActions : browsingQuickActions).where(isVisible).map(
(action) => _toActionButton(action, enabled: canApply(action)),
);
return [ return [
if (!isSelecting && appMode.canSearch) ...quickActionButtons,
CollectionSearchButton( MenuIconTheme(
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>( child: PopupMenuButton<EntrySetAction>(
// key is expected by test driver // key is expected by test driver
key: const Key('appbar-menu-button'), key: const Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
final groupable = collection.sortFactor == EntrySortFactor.date; final generalMenuItems = EntrySetActions.general.where(isVisible).map(
final selection = context.read<Selection<AvesEntry>>(); (action) => _toMenuItem(action, enabled: canApply(action)),
final isSelecting = selection.isSelecting; );
final selectedItems = selection.selectedItems;
final hasSelection = selectedItems.isNotEmpty; final browsingMenuActions = EntrySetActions.browsing.where((v) => !browsingQuickActions.contains(v));
final hasItems = !collection.isEmpty; final selectionMenuActions = EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v));
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); 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.editDate,
EntrySetAction.removeMetadata,
].map((action) => _toMenuItem(action, enabled: canApply(action))),
],
),
),
];
return [ return [
_toMenuItem(EntrySetAction.sort), ...generalMenuItems,
if (groupable) _toMenuItem(EntrySetAction.group), if (contextualMenuItems.isNotEmpty) ...[
if (appMode == AppMode.main) ...[
if (!isSelecting)
_toMenuItem(
EntrySetAction.select,
enabled: hasItems,
),
const PopupMenuDivider(), const PopupMenuDivider(),
if (isSelecting) ...EntrySetActions.selection.where((v) => !selectionQuickActions.contains(v)).map((v) => _toMenuItem(v, enabled: hasSelection)), ...contextualMenuItems,
if (!isSelecting)
...[
EntrySetAction.map,
EntrySetAction.stats,
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
if (!isSelecting && canAddShortcuts) ...[
const PopupMenuDivider(),
_toMenuItem(EntrySetAction.addShortcut),
], ],
],
if (isSelecting) ...[
const PopupMenuDivider(),
_toMenuItem(
EntrySetAction.selectAll,
enabled: selectedItems.length < collection.entryCount,
),
_toMenuItem(
EntrySetAction.selectNone,
enabled: hasSelection,
),
]
]; ];
}, },
onSelected: (action) async { onSelected: (action) async {
// wait for the popup menu to hide before proceeding with the action // wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation); await Future.delayed(Durations.popupMenuAnimation * timeDilation);
await _onCollectionActionSelected(action); await _onActionSelected(action);
}, },
), ),
);
},
), ),
]; ];
} }
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {bool enabled = true}) {
return PopupMenuItem(
// key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map') // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'), 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: _getActionKey(action),
value: action, value: action,
enabled: enabled, 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() { 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>>(); final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) { if (selection.isSelecting) {
final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); 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) { switch (action) {
case EntrySetAction.share: // general
case EntrySetAction.delete: case EntrySetAction.sort:
case EntrySetAction.copy: await _sort();
case EntrySetAction.move: break;
case EntrySetAction.rescan: case EntrySetAction.group:
case EntrySetAction.map: await _group();
case EntrySetAction.stats:
_actionDelegate.onActionSelected(context, action);
break; break;
case EntrySetAction.select: case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select(); context.read<Selection<AvesEntry>>().select();
@ -296,30 +422,30 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case EntrySetAction.selectNone: case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection(); context.read<Selection<AvesEntry>>().clearSelection();
break; break;
// browsing
case EntrySetAction.searchCollection:
case EntrySetAction.toggleTitleSearch:
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
unawaited(_showShortcutDialog(context)); // 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; 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:
Future<void> _sort() async {
final value = await showDialog<EntrySortFactor>( final value = await showDialog<EntrySortFactor>(
context: context, context: context,
builder: (context) => AvesSelectionDialog<EntrySortFactor>( builder: (context) => AvesSelectionDialog<EntrySortFactor>(
@ -337,33 +463,30 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
if (value != null) { if (value != null) {
settings.collectionSortFactor = value; settings.collectionSortFactor = value;
} }
break;
}
} }
Future<void> _showShortcutDialog(BuildContext context) async { Future<void> _group() async {
final filters = collection.filters; final value = await showDialog<EntryGroupFactor>(
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, context: context,
builder: (context) => AddShortcutDialog( builder: (context) {
collection: collection, final l10n = context.l10n;
defaultName: defaultName ?? '', 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,
); );
if (result == null) return; },
);
final coverEntry = result.item1; // wait for the dialog to hide as applying the change may block the UI
final name = result.item2; await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (name.isEmpty) return; if (value != null) {
settings.collectionSectionFactor = value;
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); }
} }
void _goToSearch() { 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/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.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/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/common/providers/selection_provider.dart';
import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -39,6 +40,7 @@ class _CollectionPageState extends State<CollectionPage> {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SelectionProvider<AvesEntry>( body: SelectionProvider<AvesEntry>(
child: QueryProvider(
child: Builder( child: Builder(
builder: (context) => WillPopScope( builder: (context) => WillPopScope(
onWillPop: () { onWillPop: () {
@ -66,6 +68,7 @@ class _CollectionPageState extends State<CollectionPage> {
), ),
), ),
), ),
),
drawer: const AppDrawer(), drawer: const AppDrawer(),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
), ),

View file

@ -1,59 +1,188 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/query.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.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/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/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.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/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.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_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/map/map_page.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:aves/widgets/stats/stats_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.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) { void onActionSelected(BuildContext context, EntrySetAction action) {
switch (action) { switch (action) {
case EntrySetAction.share: // general
_share(context); case EntrySetAction.sort:
case EntrySetAction.group:
case EntrySetAction.select:
case EntrySetAction.selectAll:
case EntrySetAction.selectNone:
break; break;
case EntrySetAction.delete: // browsing
_showDeleteDialog(context); case EntrySetAction.searchCollection:
_goToSearch(context);
break; break;
case EntrySetAction.copy: case EntrySetAction.toggleTitleSearch:
_moveSelection(context, moveType: MoveType.copy); context.read<Query>().toggle();
break; break;
case EntrySetAction.move: case EntrySetAction.addShortcut:
_moveSelection(context, moveType: MoveType.move); _addShortcut(context);
break;
case EntrySetAction.rescan:
_rescan(context);
break; break;
// browsing or selecting
case EntrySetAction.map: case EntrySetAction.map:
_goToMap(context); _goToMap(context);
break; break;
case EntrySetAction.stats: case EntrySetAction.stats:
_goToStats(context); _goToStats(context);
break; 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; break;
} }
} }
@ -81,7 +210,60 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
selection.browse(); 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 l10n = context.l10n;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>(); 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 // do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection // 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 copy = moveType == MoveType.copy;
final todoCount = todoEntries.length; final todoCount = todoItems.length;
assert(todoCount > 0); assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum); final destinationDirectory = Directory(destinationAlbum);
final names = [ 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, // do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums // as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
@ -139,7 +321,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: mediaFileService.move( opStream: mediaFileService.move(
todoEntries, todoItems,
copy: copy, copy: copy,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy, nameConflictStrategy: nameConflictStrategy,
@ -149,7 +331,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet(); final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet();
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: todoEntries, todoEntries: todoItems,
copy: copy, copy: copy,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
movedOps: movedOps, movedOps: movedOps,
@ -213,57 +395,128 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
); );
} }
Future<void> _showDeleteDialog(BuildContext context) async { Future<void> _edit(
final source = context.read<CollectionSource>(); BuildContext context,
final selection = context.read<Selection<AvesEntry>>(); Selection<AvesEntry> selection,
final selectedItems = _getExpandedSelectedItems(selection); Set<AvesEntry> todoItems,
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); Future<bool> Function(AvesEntry entry) op,
final todoCount = selectedItems.length; ) 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>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
final l10n = context.l10n;
return AvesDialog( return AvesDialog(
context: context, context: context,
content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(todoCount)), title: l10n.unsupportedTypeDialogTitle,
content: Text(l10n.unsupportedTypeDialogMessage(unsupportedTypes.length, unsupportedTypes.join(', '))),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel), child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
), ),
if (supported.isNotEmpty)
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.deleteButtonLabel), 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();
final deletedCount = deletedUris.length;
if (deletedCount < todoCount) {
final count = todoCount - deletedCount;
showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count));
} }
// cleanup Future<void> _rotate(BuildContext context, {required bool clockwise}) async {
await storageService.deleteEmptyDirectories(selectionDirs); 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.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) { 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:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FilterBar extends StatefulWidget implements PreferredSizeWidget { class FilterBar extends StatefulWidget {
static const double verticalPadding = 16; static const double verticalPadding = 16;
static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding;
@ -19,9 +19,6 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
}) : filters = List<CollectionFilter>.from(filters)..sort(), }) : filters = List<CollectionFilter>.from(filters)..sort(),
super(key: key); super(key: key);
@override
final Size preferredSize = const Size.fromHeight(preferredHeight);
@override @override
_FilterBarState createState() => _FilterBarState(); _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 // when the user is not dragging the thumb
if (!_isDragInProcess) { if (!_isDragInProcess) {
if (notification is ScrollUpdateNotification) { 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) { 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 { class LinkChip extends StatelessWidget {
final Widget? leading; final Widget? leading;
final String text; final String text;
final String url; final String? url;
final Color? color; final Color? color;
final TextStyle? textStyle; final TextStyle? textStyle;
final VoidCallback? onTap;
static const borderRadius = BorderRadius.all(Radius.circular(8)); static const borderRadius = BorderRadius.all(Radius.circular(8));
@ -15,20 +16,23 @@ class LinkChip extends StatelessWidget {
Key? key, Key? key,
this.leading, this.leading,
required this.text, required this.text,
required this.url, this.url,
this.color, this.color,
this.textStyle, this.textStyle,
this.onTap,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final _url = url;
return DefaultTextStyle.merge( return DefaultTextStyle.merge(
style: (textStyle ?? const TextStyle()).copyWith(color: color), style: (textStyle ?? const TextStyle()).copyWith(color: color),
child: InkWell( child: InkWell(
borderRadius: borderRadius, borderRadius: borderRadius,
onTap: () async { onTap: onTap ??
if (await canLaunch(url)) { () async {
await launch(url); if (_url != null && await canLaunch(_url)) {
await launch(_url);
} }
}, },
child: Padding( child: Padding(

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:flutter/material.dart';
import 'package:provider/provider.dart';
class MenuRow extends StatelessWidget { class MenuRow extends StatelessWidget {
final String text; 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'; import 'package:flutter/widgets.dart';
class QueryBar extends StatefulWidget { 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({ const QueryBar({
Key? key, Key? key,
required this.filterNotifier, required this.queryNotifier,
this.focusNode,
this.icon,
this.hintText,
this.editable = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -22,22 +30,24 @@ class _QueryBarState extends State<QueryBar> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
late TextEditingController _controller; late TextEditingController _controller;
ValueNotifier<String> get filterNotifier => widget.filterNotifier; ValueNotifier<String> get queryNotifier => widget.queryNotifier;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: filterNotifier.value); _controller = TextEditingController(text: queryNotifier.value);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final clearButton = IconButton( final clearButton = IconButton(
icon: const Icon(AIcons.clear), icon: const Icon(AIcons.clear),
onPressed: () { onPressed: widget.editable
? () {
_controller.clear(); _controller.clear();
filterNotifier.value = ''; queryNotifier.value = '';
}, }
: null,
tooltip: context.l10n.clearTooltip, tooltip: context.l10n.clearTooltip,
); );
@ -47,16 +57,18 @@ class _QueryBarState extends State<QueryBar> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _controller, controller: _controller,
focusNode: widget.focusNode ?? FocusNode(),
decoration: InputDecoration( decoration: InputDecoration(
icon: const Padding( icon: Padding(
padding: EdgeInsetsDirectional.only(start: 16), padding: const EdgeInsetsDirectional.only(start: 16),
child: Icon(AIcons.search), child: Icon(widget.icon ?? AIcons.filter),
), ),
hintText: MaterialLocalizations.of(context).searchFieldLabel, hintText: widget.hintText ?? MaterialLocalizations.of(context).searchFieldLabel,
hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, hintStyle: Theme.of(context).inputDecorationTheme.hintStyle,
), ),
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onChanged: (s) => _debouncer(() => filterNotifier.value = s), onChanged: (s) => _debouncer(() => queryNotifier.value = s.trim()),
enabled: widget.editable,
), ),
), ),
ConstrainedBox( ConstrainedBox(
@ -73,7 +85,7 @@ class _QueryBarState extends State<QueryBar> {
child: child, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = ButtonStyle( final style = ButtonStyle(
side: MaterialStateProperty.all<BorderSide>(BorderSide(color: Theme.of(context).colorScheme.secondary)), side: MaterialStateProperty.resolveWith<BorderSide>((states) {
foregroundColor: MaterialStateProperty.all<Color>(Colors.white), 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 return icon != null
? OutlinedButton.icon( ? OutlinedButton.icon(

View file

@ -12,6 +12,7 @@ class MagnifierController {
final StreamController<ScaleBoundaries> _scaleBoundariesStreamController = StreamController.broadcast(); final StreamController<ScaleBoundaries> _scaleBoundariesStreamController = StreamController.broadcast();
final StreamController<ScaleStateChange> _scaleStateChangeStreamController = StreamController.broadcast(); final StreamController<ScaleStateChange> _scaleStateChangeStreamController = StreamController.broadcast();
bool _disposed = false;
late MagnifierState _currentState, initial, previousState; late MagnifierState _currentState, initial, previousState;
ScaleBoundaries? _scaleBoundaries; ScaleBoundaries? _scaleBoundaries;
late ScaleStateChange _currentScaleState, previousScaleState; late ScaleStateChange _currentScaleState, previousScaleState;
@ -54,8 +55,8 @@ class MagnifierController {
bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut; bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut;
/// Closes streams and removes eventual listeners.
void dispose() { void dispose() {
_disposed = true;
_stateStreamController.close(); _stateStreamController.close();
_scaleBoundariesStreamController.close(); _scaleBoundariesStreamController.close();
_scaleStateChangeStreamController.close(); _scaleStateChangeStreamController.close();
@ -79,23 +80,25 @@ class MagnifierController {
} }
void setScaleState(ScaleState newValue, ChangeSource source, {Offset? childFocalPoint}) { void setScaleState(ScaleState newValue, ChangeSource source, {Offset? childFocalPoint}) {
if (_currentScaleState.state == newValue) return; if (_disposed || _currentScaleState.state == newValue) return;
previousScaleState = _currentScaleState; previousScaleState = _currentScaleState;
_currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint); _currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint);
_scaleStateChangeStreamController.sink.add(scaleState); _scaleStateChangeStreamController.add(scaleState);
} }
void _setState(MagnifierState state) { void _setState(MagnifierState state) {
if (_currentState == state) return; if (_disposed || _currentState == state) return;
_currentState = state; _currentState = state;
_stateStreamController.sink.add(state); _stateStreamController.add(state);
} }
void setScaleBoundaries(ScaleBoundaries scaleBoundaries) { void setScaleBoundaries(ScaleBoundaries scaleBoundaries) {
if (_scaleBoundaries == scaleBoundaries) return; if (_disposed || _scaleBoundaries == scaleBoundaries) return;
_scaleBoundaries = scaleBoundaries; _scaleBoundaries = scaleBoundaries;
_scaleBoundariesStreamController.sink.add(scaleBoundaries); _scaleBoundariesStreamController.add(scaleBoundaries);
if (!isZooming) { if (!isZooming) {
update( update(
@ -106,9 +109,10 @@ class MagnifierController {
} }
void _setScaleState(ScaleStateChange scaleState) { void _setScaleState(ScaleStateChange scaleState) {
if (_currentScaleState == scaleState) return; if (_disposed || _currentScaleState == scaleState) return;
_currentScaleState = scaleState; _currentScaleState = scaleState;
_scaleStateChangeStreamController.sink.add(_currentScaleState); _scaleStateChangeStreamController.add(_currentScaleState);
} }
double? getScaleForScaleState(ScaleState scaleState) { 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; _lastException = null;
_providers.clear(); _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([ _providers.addAll([
if (widget.progressive && !entry.isSvg) if (lowQuality != null)
_ConditionalImageProvider( _ConditionalImageProvider(
ScrollAwareImageProvider( ScrollAwareImageProvider(
context: _scrollAwareContext, context: _scrollAwareContext,
imageProvider: entry.getThumbnail(), imageProvider: lowQuality,
), ),
), ),
_ConditionalImageProvider( _ConditionalImageProvider(
ScrollAwareImageProvider( ScrollAwareImageProvider(
context: _scrollAwareContext, context: _scrollAwareContext,
imageProvider: entry.getThumbnail(extent: extent), imageProvider: highQuality,
), ),
_needSizedProvider, _needSizedProvider,
), ),
@ -233,7 +249,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
if (animate && widget.heroTag != null) { if (animate && widget.heroTag != null) {
final background = settings.imageBackground; final background = settings.imageBackground;
final backgroundColor = background.isColor? background.color : null; final backgroundColor = background.isColor ? background.color : null;
image = Hero( image = Hero(
tag: widget.heroTag!, tag: widget.heroTag!,
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) { flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {

View file

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

View file

@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.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/services/common/services.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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<List<AddressDetails>> _dbAddressLoader;
late Future<Set<FavouriteRow>> _dbFavouritesLoader; late Future<Set<FavouriteRow>> _dbFavouritesLoader;
late Future<Set<CoverRow>> _dbCoversLoader; late Future<Set<CoverRow>> _dbCoversLoader;
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
@override @override
void initState() { 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() { void _startDbReport() {
_dbFileSizeLoader = metadataDb.dbFileSize(); _dbFileSizeLoader = metadataDb.dbFileSize();
_dbEntryLoader = metadataDb.loadEntries(); _dbEntryLoader = metadataDb.loadAllEntries();
_dbDateLoader = metadataDb.loadDates(); _dbDateLoader = metadataDb.loadDates();
_dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbMetadataLoader = metadataDb.loadAllMetadataEntries();
_dbAddressLoader = metadataDb.loadAddresses(); _dbAddressLoader = metadataDb.loadAllAddresses();
_dbFavouritesLoader = metadataDb.loadFavourites(); _dbFavouritesLoader = metadataDb.loadAllFavourites();
_dbCoversLoader = metadataDb.loadCovers(); _dbCoversLoader = metadataDb.loadAllCovers();
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();
setState(() {}); 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/services/common/services.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DebugFirebaseSection extends StatelessWidget { class DebugErrorReportingSection extends StatelessWidget {
const DebugFirebaseSection({Key? key}) : super(key: key); const DebugErrorReportingSection({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -51,10 +50,7 @@ class DebugFirebaseSection extends StatelessWidget {
Padding( Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup( child: InfoRowGroup(
info: { info: reportService.state,
'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}',
'Crashlytics collection enabled': '${reportService.isCollectionEnabled}',
},
), ),
) )
], ],

View file

@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class AvesDialog extends AlertDialog { 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 controlCaptionPadding = 16;
static const double borderWidth = 1.0; static const double borderWidth = 1.0;
@ -16,6 +16,7 @@ class AvesDialog extends AlertDialog {
ScrollController? scrollController, ScrollController? scrollController,
List<Widget>? scrollableContent, List<Widget>? scrollableContent,
bool hasScrollBar = true, bool hasScrollBar = true,
double horizontalContentPadding = defaultHorizontalContentPadding,
Widget? content, Widget? content,
required List<Widget> actions, required List<Widget> actions,
}) : assert((scrollableContent != null) ^ (content != null)), }) : assert((scrollableContent != null) ^ (content != null)),
@ -34,7 +35,7 @@ class AvesDialog extends AlertDialog {
// and overflow feedback ignores the dialog shape, // and overflow feedback ignores the dialog shape,
// so we restrict scrolling to the content instead // so we restrict scrolling to the content instead
content: _buildContent(context, scrollController, scrollableContent, hasScrollBar, content), 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, actions: actions,
actionsPadding: const EdgeInsets.symmetric(horizontal: 8), actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -115,7 +116,7 @@ class DialogTitle extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth), bottom: Divider.createBorderSide(context, width: AvesDialog.borderWidth),
@ -127,6 +128,7 @@ class DialogTitle extends StatelessWidget {
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
fontFeatures: [FontFeature.enable('smcp')], fontFeatures: [FontFeature.enable('smcp')],
), ),
textAlign: TextAlign.center,
), ),
); );
} }

View file

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

View file

@ -44,6 +44,8 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
final volumeTiles = <Widget>[]; final volumeTiles = <Widget>[];
if (_allVolumes.length > 1) { if (_allVolumes.length > 1) {
final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary); final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary);
@ -52,7 +54,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final otherVolumes = (byPrimary[false] ?? [])..sort(compare); final otherVolumes = (byPrimary[false] ?? [])..sort(compare);
volumeTiles.addAll([ volumeTiles.addAll([
Padding( Padding(
padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(top: 20), padding: contentHorizontalPadding + const EdgeInsets.only(top: 20),
child: Text(context.l10n.newAlbumDialogStorageLabel), child: Text(context.l10n.newAlbumDialogStorageLabel),
), ),
...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)), ...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)),
@ -68,7 +70,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
scrollableContent: [ scrollableContent: [
...volumeTiles, ...volumeTiles,
Padding( Padding(
padding: AvesDialog.contentHorizontalPadding + const EdgeInsets.only(bottom: 8), padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>( child: ValueListenableBuilder<bool>(
valueListenable: _existsNotifier, valueListenable: _existsNotifier,
builder: (context, exists, child) { 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/format.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -44,7 +45,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Builder(
builder: (context) {
final l10n = context.l10n; final l10n = context.l10n;
final locale = l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
void _updateAction(DateEditAction? action) { void _updateAction(DateEditAction? action) {
if (action == null) return; if (action == null) return;
setState(() => _action = action); setState(() => _action = action);
@ -65,7 +72,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
groupValue: _action, groupValue: _action,
onChanged: _updateAction, onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogSet), title: _tileText(l10n.editEntryDateDialogSet),
subtitle: Text(formatDateTime(_dateTime, l10n.localeName)), subtitle: Text(formatDateTime(_dateTime, locale, use24hour)),
), ),
), ),
Padding( Padding(
@ -99,6 +106,12 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
), ),
], ],
); );
final extractFromTitleTile = RadioListTile<DateEditAction>(
value: DateEditAction.extractFromTitle,
groupValue: _action,
onChanged: _updateAction,
title: _tileText(l10n.editEntryDateDialogExtractFromTitle),
);
final clearTile = RadioListTile<DateEditAction>( final clearTile = RadioListTile<DateEditAction>(
value: DateEditAction.clear, value: DateEditAction.clear,
groupValue: _action, groupValue: _action,
@ -121,6 +134,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
scrollableContent: [ scrollableContent: [
setTile, setTile,
shiftTile, shiftTile,
extractFromTitleTile,
clearTile, clearTile,
Padding( Padding(
padding: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.only(bottom: 1),
@ -165,6 +179,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
], ],
), ),
); );
},
),
);
} }
String _formatShiftDuration() { String _formatShiftDuration() {
@ -233,6 +250,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
case DateEditAction.shift: case DateEditAction.shift:
modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes); modifier = DateModifier(_action, _fields, shiftMinutes: _shiftMinutes);
break; break;
case DateEditAction.extractFromTitle:
case DateEditAction.clear: case DateEditAction.clear:
modifier = DateModifier(_action, _fields); modifier = DateModifier(_action, _fields);
break; 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/collection/collection_page.dart';
import 'package:aves/widgets/common/basic/insets.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/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/common/providers/selection_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -39,6 +40,7 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
child: MediaQueryDataProvider( child: MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SelectionProvider<AvesEntry>( body: SelectionProvider<AvesEntry>(
child: QueryProvider(
child: GestureAreaProtectorStack( child: GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
@ -53,6 +55,7 @@ class _ItemPickDialogState extends State<ItemPickDialog> {
), ),
), ),
), ),
),
); );
} }
} }

View file

@ -1,7 +1,5 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/brand_colors.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -14,11 +12,11 @@ import 'package:provider/provider.dart';
import 'aves_dialog.dart'; import 'aves_dialog.dart';
class RemoveEntryMetadataDialog extends StatefulWidget { class RemoveEntryMetadataDialog extends StatefulWidget {
final AvesEntry entry; final bool showJpegTypes;
const RemoveEntryMetadataDialog({ const RemoveEntryMetadataDialog({
Key? key, Key? key,
required this.entry, required this.showJpegTypes,
}) : super(key: key); }) : super(key: key);
@override @override
@ -31,14 +29,12 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
bool _showMore = false; bool _showMore = false;
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
AvesEntry get entry => widget.entry;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final byMain = groupBy([ final byMain = groupBy([
...MetadataTypes.common, ...MetadataTypes.common,
if (entry.mimeType == MimeTypes.jpeg) ...MetadataTypes.jpeg, if (widget.showJpegTypes) ...MetadataTypes.jpeg,
], MetadataTypes.main.contains); ], MetadataTypes.main.contains);
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText); _mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText); _moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);

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