diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 555bbde2e..75ba9a301 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: subosito/flutter-action@v1 with: - channel: stable - flutter-version: '1.22.6' + channel: dev + flutter-version: '2.1.0-12.1.pre' - name: Clone the repository. uses: actions/checkout@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d35450f7f..900706c0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,8 @@ jobs: - uses: subosito/flutter-action@v1 with: - channel: stable - flutter-version: '1.22.6' + channel: dev + flutter-version: '2.1.0-12.1.pre' # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # https://issuetracker.google.com/issues/144111441 @@ -50,8 +50,8 @@ jobs: echo "${{ secrets.KEY_JKS }}" > release.keystore.asc gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE rm release.keystore.asc - flutter build apk --bundle-sksl-path shaders_1.22.6.sksl.json - flutter build appbundle --bundle-sksl-path shaders_1.22.6.sksl.json + flutter build apk --bundle-sksl-path shaders_2.1.0-12.1.pre.sksl.json + flutter build appbundle --bundle-sksl-path shaders_2.1.0-12.1.pre.sksl.json rm $AVES_STORE_FILE env: AVES_STORE_FILE: ${{ github.workspace }}/key.jks diff --git a/CHANGELOG.md b/CHANGELOG.md index b66683330..6be5a8796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.3.6] - 2021-03-18 +### Added +- Korean translation +- cover selection for albums / countries / tags + +### Changed +- Upgraded Flutter to dev v2.1.0-12.1.pre + +### Fixed +- various TIFF decoding fixes + ## [v1.3.5] - 2021-02-26 ### Added - support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23) diff --git a/README.md b/README.md index 81b930ab2..94b801598 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutter. -Collection screenshotImage screenshotStats screenshot +Collection screenshotImage screenshotStats screenshot ## Features diff --git a/android/app/build.gradle b/android/app/build.gradle index 5247b3a54..f33882cb3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -104,11 +104,11 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' - implementation 'androidx.core:core-ktx:1.5.0-beta01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts + implementation 'androidx.core:core-ktx:1.5.0-beta03' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts implementation 'androidx.exifinterface:exifinterface:1.3.2' implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.15.0' - implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack + implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack implementation 'com.github.bumptech.glide:glide:4.12.0' kapt 'androidx.annotation:annotation:1.1.0' diff --git a/android/app/src/debug/res/values-ko/strings.xml b/android/app/src/debug/res/values-ko/strings.xml new file mode 100644 index 000000000..4fff58c6e --- /dev/null +++ b/android/app/src/debug/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + 아베스 [Debug] + \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 7e4504e63..7f43f5215 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -34,6 +34,7 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) + MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt new file mode 100644 index 000000000..874c32d97 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt @@ -0,0 +1,82 @@ +package deckers.thibault.aves.channel.calls + +import android.content.Context +import android.location.Geocoder +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.* + +// as of 2021/03/10, geocoding packages exist but: +// - `geocoder` is unmaintained +// - `geocoding` method does not return `addressLine` (v2.0.0) +class GeocodingHandler(private val context: Context) : MethodCallHandler { + private var geocoder: Geocoder? = null + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getAddress" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::getAddress) } + else -> result.notImplemented() + } + } + + private fun getAddress(call: MethodCall, result: MethodChannel.Result) { + val latitude = call.argument("latitude")?.toDouble() + val longitude = call.argument("longitude")?.toDouble() + val localeString = call.argument("locale") + val maxResults = call.argument("maxResults") ?: 1 + if (latitude == null || longitude == null) { + result.error("getAddress-args", "failed because of missing arguments", null) + return + } + + if (!Geocoder.isPresent()) { + result.error("getAddress-unavailable", "Geocoder is unavailable", null) + return + } + + geocoder = geocoder ?: if (localeString != null) { + val split = localeString.split("_") + val language = split[0] + val country = if (split.size > 1) split[1] else "" + Geocoder(context, Locale(language, country)) + } else { + Geocoder(context) + } + + val addresses = try { + geocoder!!.getFromLocation(latitude, longitude, maxResults) ?: ArrayList() + } catch (e: Exception) { + result.error("getAddress-exception", "failed to get address", e.message) + return + } + + if (addresses.isEmpty()) { + result.error("getAddress-empty", "failed to find any address for latitude=$latitude, longitude=$longitude", null) + } else { + val addressMapList: ArrayList> = ArrayList(addresses.map { address -> + hashMapOf( + "addressLine" to (0..address.maxAddressLineIndex).joinToString(", ") { i -> address.getAddressLine(i) }, + "adminArea" to address.adminArea, + "countryCode" to address.countryCode, + "countryName" to address.countryName, + "featureName" to address.featureName, + "locality" to address.locality, + "postalCode" to address.postalCode, + "subAdminArea" to address.subAdminArea, + "subLocality" to address.subLocality, + "subThoroughfare" to address.subThoroughfare, + "thoroughfare" to address.thoroughfare, + ) + }) + result.success(addressMapList) + } + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/geocoding" + } +} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index d35b00134..5b8c69cd7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -18,6 +18,7 @@ import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.drew.imaging.ImageMetadataReader import com.drew.lang.Rational +import com.drew.metadata.Tag import com.drew.metadata.exif.* import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.gif.GifAnimationDirectory @@ -47,7 +48,9 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.XMP.getSafeDateMillis +import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText +import deckers.thibault.aves.metadata.XMP.getSafeString import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.provider.FileImageProvider @@ -123,17 +126,29 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[dirName] = dirMap // tags + val tags = dir.tags if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { - dirMap.putAll(dir.tags.map { + fun tagMapper(it: Tag): Pair { val name = if (it.hasTagName()) { it.tagName } else { TiffTags.getTagName(it.tagType) ?: it.tagName } - Pair(name, it.description) - }) + return Pair(name, it.description) + } + + if (dir is ExifIFD0Directory && dir.isGeoTiff()) { + // split GeoTIFF tags in their own directory + val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) } + metadataMap["GeoTIFF"] = HashMap().apply { + byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) } + } + byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) } + } else { + dirMap.putAll(tags.map { tagMapper(it) }) + } } else { - dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) }) + dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) } if (dir is XmpDirectory) { try { @@ -593,10 +608,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { KEY_PAGE to i, KEY_MIME_TYPE to trackMime, ) + + // do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks + // e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1 + format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it } - } format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } if (isVideo(trackMime)) { @@ -626,25 +642,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { try { Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> val metadata = ImageMetadataReader.readMetadata(input) - val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) - try { - fun getIntProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null } - fun getStringProp(propName: String): String? = xmpDirs.map { it.xmpMeta.getPropertyString(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null } - val fields: FieldMap = hashMapOf( - "croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME), - "croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME), - "croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME), - "croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME), - "fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME), - "fullPanoHeight" to getIntProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME), - "projectionType" to (getStringProp(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT), - ) - result.success(fields) - return - } catch (e: XMPException) { - result.error("getPanoramaInfo-args", "failed to read XMP for uri=$uri", e.message) - return + val fields = hashMapOf( + "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT, + ) + for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { + val xmpMeta = dir.xmpMeta + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it } + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it } + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it } + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it } + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it } + xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it } + xmpMeta.getSafeString(XMP.GPANO_SCHEMA_NS, XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it } } + result.success(fields) + return } } catch (e: Exception) { Log.w(LOG_TAG, "failed to read XMP", e) @@ -875,7 +887,6 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { private const val KEY_HEIGHT = "height" private const val KEY_WIDTH = "width" private const val KEY_PAGE = "page" - private const val KEY_TRACK_ID = "trackId" private const val KEY_IS_DEFAULT = "isDefault" private const val KEY_DURATION = "durationMillis" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt index 290b7badd..80a158fab 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/MultiTrackImageGlideModule.kt @@ -27,7 +27,7 @@ class MultiTrackImageGlideModule : LibraryGlideModule() { } } -class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?) +class MultiTrackImage(val context: Context, val uri: Uri, val trackIndex: Int?) internal class MultiTrackThumbnailLoader : ModelLoader { override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData { @@ -52,9 +52,9 @@ internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int val context = model.context val uri = model.uri - val trackId = model.trackId + val trackIndex = model.trackIndex - val bitmap = MultiTrackMedia.getImage(context, uri, trackId) + val bitmap = MultiTrackMedia.getImage(context, uri, trackIndex) if (bitmap == null) { callback.onLoadFailed(Exception("null bitmap")) } else { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt index 8ac8dad05..acd72a5d5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiTrackMedia.kt @@ -16,17 +16,17 @@ object MultiTrackMedia { private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java) @RequiresApi(Build.VERSION_CODES.P) - fun getImage(context: Context, uri: Uri, trackId: Int?): Bitmap? { + fun getImage(context: Context, uri: Uri, trackIndex: Int?): Bitmap? { val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return null try { - return if (trackId != null) { - val imageIndex = trackIdToImageIndex(context, uri, trackId) ?: return null + return if (trackIndex != null) { + val imageIndex = trackIndexToImageIndex(context, uri, trackIndex) ?: return null retriever.getImageAtIndex(imageIndex) } else { retriever.primaryImage } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to extract image from uri=$uri trackId=$trackId", e) + Log.w(LOG_TAG, "failed to extract image from uri=$uri trackIndex=$trackIndex", e) } finally { // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs retriever.release() @@ -34,7 +34,7 @@ object MultiTrackMedia { return null } - private fun trackIdToImageIndex(context: Context, uri: Uri, trackId: Int): Int? { + private fun trackIndexToImageIndex(context: Context, uri: Uri, trackIndex: Int): Int? { val extractor = MediaExtractor() try { extractor.setDataSource(context, uri, null) @@ -42,7 +42,7 @@ object MultiTrackMedia { var imageIndex = 0 for (i in 0 until trackCount) { val trackFormat = extractor.getTrackFormat(i) - if (trackId == trackFormat.getInteger(MediaFormat.KEY_TRACK_ID)) { + if (trackIndex == i) { return imageIndex } if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) { @@ -50,7 +50,7 @@ object MultiTrackMedia { } } } catch (e: Exception) { - Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackId=$trackId", e) + Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackIndex=$trackIndex", e) } finally { extractor.release() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt index 6fd91d5e1..230c1e630 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/TiffTags.kt @@ -110,6 +110,15 @@ object TiffTags { // Count = variable const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b + private val geotiffTags = listOf( + TAG_GEO_ASCII_PARAMS, + TAG_GEO_DOUBLE_PARAMS, + TAG_GEO_KEY_DIRECTORY, + TAG_MODEL_PIXEL_SCALE, + TAG_MODEL_TIEPOINT, + TAG_MODEL_TRANSFORMATION, + ) + private val tagNameMap = hashMapOf( TAG_X_POSITION to "X Position", TAG_Y_POSITION to "Y Position", @@ -132,6 +141,8 @@ object TiffTags { TAG_ORIGINAL_RAW_FILE_NAME to "Original Raw File Name", ) + fun isGeoTiffTag(tag: Int) = geotiffTags.contains(tag) + fun getTagName(tag: Int): String? { return tagNameMap[tag] } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 0ca46696b..6d411f1e5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -97,6 +97,34 @@ object XMP { return false } + fun XMPMeta.getSafeInt(schema: String, propName: String, save: (value: Int) -> Unit) { + try { + if (doesPropertyExist(schema, propName)) { + val item = getPropertyInteger(schema, propName) + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + save(item) + } + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get int for XMP schema=$schema, propName=$propName", e) + } + } + + fun XMPMeta.getSafeString(schema: String, propName: String, save: (value: String) -> Unit) { + try { + if (doesPropertyExist(schema, propName)) { + val item = getPropertyString(schema, propName) + // double check retrieved items as the property sometimes is reported to exist but it is actually null + if (item != null) { + save(item) + } + } + } catch (e: XMPException) { + Log.w(LOG_TAG, "failed to get int for XMP schema=$schema, propName=$propName", e) + } + } + fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) { try { if (doesPropertyExist(schema, propName)) { diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml new file mode 100644 index 000000000..2c44e7463 --- /dev/null +++ b/android/app/src/main/res/values-ko/strings.xml @@ -0,0 +1,6 @@ + + + 아베스 + 검색 + 동영상 + \ No newline at end of file diff --git a/android/app/src/profile/res/values-ko/strings.xml b/android/app/src/profile/res/values-ko/strings.xml new file mode 100644 index 000000000..37f84623f --- /dev/null +++ b/android/app/src/profile/res/values-ko/strings.xml @@ -0,0 +1,4 @@ + + + 아베스 [Profile] + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 3a1c6361f..657711f4d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,18 +1,21 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.4.30' + ext.kotlin_version = '1.4.31' repositories { google() mavenCentral() // TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387 - jcenter() + jcenter { + content { + includeModule("org.jetbrains.trove4j", "trove4j") + } + } } dependencies { - // TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/commit/8dd0de7f580972079f610a56a689b0a9c414f81e - classpath 'com.android.tools.build:gradle:3.6.4' + classpath 'com.android.tools.build:gradle:4.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.3.5' - classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.1' } } @@ -21,7 +24,11 @@ allprojects { google() mavenCentral() // TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387 - jcenter() + jcenter { + content { + includeModule("org.jetbrains.trove4j", "trove4j") + } + } } // gradle.projectsEvaluated { // tasks.withType(JavaCompile) { diff --git a/android/gradle.properties b/android/gradle.properties index a33b0802a..3422ffa05 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -15,4 +15,3 @@ android.useAndroidX=true android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.enableR8=true diff --git a/extra/aves_icon.svg b/extra/aves_icon.svg deleted file mode 100644 index e52947bfc..000000000 --- a/extra/aves_icon.svg +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - diff --git a/extra/play/feature_graphics_1024_500.png b/extra/play/feature_graphics_1024_500.png deleted file mode 100644 index f97296a56..000000000 Binary files a/extra/play/feature_graphics_1024_500.png and /dev/null differ diff --git a/extra/play/feature_graphics_1024_500.xcf b/extra/play/feature_graphics_1024_500.xcf deleted file mode 100644 index 1121ebbbf..000000000 Binary files a/extra/play/feature_graphics_1024_500.xcf and /dev/null differ diff --git a/extra/play/hi_res_512.png b/extra/play/hi_res_512.png deleted file mode 100644 index f6caff9b6..000000000 Binary files a/extra/play/hi_res_512.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/1-S10-collection.jpg b/extra/play/screenshots v1.0.0/S10/1-S10-collection.jpg deleted file mode 100644 index c7c67fc94..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/1-S10-collection.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/2-S10-image.jpg b/extra/play/screenshots v1.0.0/S10/2-S10-image.jpg deleted file mode 100644 index a7e7e5dca..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/2-S10-image.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/3-S10-info__basic_.jpg b/extra/play/screenshots v1.0.0/S10/3-S10-info__basic_.jpg deleted file mode 100644 index d454fff5f..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/3-S10-info__basic_.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/4-S10-info__metadata_.jpg b/extra/play/screenshots v1.0.0/S10/4-S10-info__metadata_.jpg deleted file mode 100644 index 52d10c4bc..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/4-S10-info__metadata_.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/5-S10-stats.jpg b/extra/play/screenshots v1.0.0/S10/5-S10-stats.jpg deleted file mode 100644 index 8e6d9e251..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/5-S10-stats.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/S10/6-S10-countries.jpg b/extra/play/screenshots v1.0.0/S10/6-S10-countries.jpg deleted file mode 100644 index eec56facf..000000000 Binary files a/extra/play/screenshots v1.0.0/S10/6-S10-countries.jpg and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/1 Collection.png b/extra/play/screenshots v1.0.0/raw/1 Collection.png deleted file mode 100644 index 8dfa36e40..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/1 Collection.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/2 Image.png b/extra/play/screenshots v1.0.0/raw/2 Image.png deleted file mode 100644 index e77b42427..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/2 Image.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/3 Info basic.png b/extra/play/screenshots v1.0.0/raw/3 Info basic.png deleted file mode 100644 index 30e803343..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/3 Info basic.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/4 Info metadata.png b/extra/play/screenshots v1.0.0/raw/4 Info metadata.png deleted file mode 100644 index 508fe4b9a..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/4 Info metadata.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/5 Stats.png b/extra/play/screenshots v1.0.0/raw/5 Stats.png deleted file mode 100644 index eda465b3a..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/5 Stats.png and /dev/null differ diff --git a/extra/play/screenshots v1.0.0/raw/6 Countries.png b/extra/play/screenshots v1.0.0/raw/6 Countries.png deleted file mode 100644 index 94a9f09a4..000000000 Binary files a/extra/play/screenshots v1.0.0/raw/6 Countries.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/1-S10-collection.png b/extra/play/screenshots v1.2.1/S10/1-S10-collection.png deleted file mode 100644 index bd67a0dab..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/1-S10-collection.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/2-S10-image.png b/extra/play/screenshots v1.2.1/S10/2-S10-image.png deleted file mode 100644 index 81159ad19..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/2-S10-image.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png b/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png deleted file mode 100644 index e42510bc1..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/3-S10-info__basic_.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png b/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png deleted file mode 100644 index 8a71700f4..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/4-S10-info__metadata_.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/5-S10-stats.png b/extra/play/screenshots v1.2.1/S10/5-S10-stats.png deleted file mode 100644 index 5d12670af..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/5-S10-stats.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/S10/6-S10-countries.png b/extra/play/screenshots v1.2.1/S10/6-S10-countries.png deleted file mode 100644 index a1f81a12a..000000000 Binary files a/extra/play/screenshots v1.2.1/S10/6-S10-countries.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/raw/1 Collection.png b/extra/play/screenshots v1.2.1/raw/1 Collection.png deleted file mode 100644 index 1aac95f0f..000000000 Binary files a/extra/play/screenshots v1.2.1/raw/1 Collection.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/raw/2 image.png b/extra/play/screenshots v1.2.1/raw/2 image.png deleted file mode 100644 index ace24ec9d..000000000 Binary files a/extra/play/screenshots v1.2.1/raw/2 image.png and /dev/null differ diff --git a/extra/play/screenshots v1.2.1/raw/3 Info basic.png b/extra/play/screenshots v1.2.1/raw/3 Info basic.png deleted file mode 100644 index 974c8267f..000000000 Binary files a/extra/play/screenshots v1.2.1/raw/3 Info basic.png and /dev/null differ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 000000000..3619cab0e --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,10 @@ +# cf guide: http://flutter.dev/go/i18n-user-guide + +# use defaults to: +# - parse ARB files from `lib/l10n` +# - generate class `AppLocalizations` in `app_localizations.dart` + +preferred-supported-locales: + - en + +# untranslated-messages-file: untranslated.json diff --git a/lib/app_mode.dart b/lib/app_mode.dart new file mode 100644 index 000000000..5238cf612 --- /dev/null +++ b/lib/app_mode.dart @@ -0,0 +1,9 @@ +enum AppMode { main, pickExternal, pickInternal, view } + +extension ExtraAppMode on AppMode { + bool get canSearch => this == AppMode.main || this == AppMode.pickExternal; + + bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal; + + bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal; +} diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index 3563d9419..136bcc626 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'dart:ui' as ui show Codec; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -32,7 +32,7 @@ class RegionProvider extends ImageProvider { final mimeType = key.mimeType; final pageId = key.pageId; try { - final bytes = await ImageFileService.getRegion( + final bytes = await imageFileService.getRegion( uri, mimeType, key.rotationDegrees, @@ -55,11 +55,11 @@ class RegionProvider extends ImageProvider { @override void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) { - ImageFileService.resumeLoading(key); + imageFileService.resumeLoading(key); super.resolveStreamForKey(configuration, stream, key, handleError); } - void pause() => ImageFileService.cancelRegion(key); + void pause() => imageFileService.cancelRegion(key); } class RegionProviderKey { diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 0f7e9d063..0578b63e3 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -1,6 +1,6 @@ import 'dart:ui' as ui show Codec; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -33,7 +33,7 @@ class ThumbnailProvider extends ImageProvider { final mimeType = key.mimeType; final pageId = key.pageId; try { - final bytes = await ImageFileService.getThumbnail( + final bytes = await imageFileService.getThumbnail( uri: uri, mimeType: mimeType, pageId: pageId, @@ -55,11 +55,11 @@ class ThumbnailProvider extends ImageProvider { @override void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { - ImageFileService.resumeLoading(key); + imageFileService.resumeLoading(key); super.resolveStreamForKey(configuration, stream, key, handleError); } - void pause() => ImageFileService.cancelThumbnail(key); + void pause() => imageFileService.cancelThumbnail(key); } class ThumbnailProviderKey { diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 6c3f9615e..c6b1c31fa 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:ui' as ui show Codec; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:pedantic/pedantic.dart'; @@ -46,7 +46,7 @@ class UriImage extends ImageProvider { assert(key == this); try { - final bytes = await ImageFileService.getImage( + final bytes = await imageFileService.getImage( uri, mimeType, rotationDegrees, diff --git a/lib/image_providers/uri_picture_provider.dart b/lib/image_providers/uri_picture_provider.dart index f6e8dc9c1..2f4071c8c 100644 --- a/lib/image_providers/uri_picture_provider.dart +++ b/lib/image_providers/uri_picture_provider.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -8,13 +8,12 @@ class UriPicture extends PictureProvider { const UriPicture({ @required this.uri, @required this.mimeType, - this.colorFilter, - }) : assert(uri != null); + ColorFilter colorFilter, + }) : assert(uri != null), + super(colorFilter); final String uri, mimeType; - final ColorFilter colorFilter; - @override Future obtainKey(PictureConfiguration configuration) { return SynchronousFuture(this); @@ -30,7 +29,7 @@ class UriPicture extends PictureProvider { Future _loadAsync(UriPicture key, {PictureErrorListener onError}) async { assert(key == this); - final data = await ImageFileService.getSvg(uri, mimeType); + final data = await imageFileService.getSvg(uri, mimeType); if (data == null || data.isEmpty) { return null; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 000000000..d093c36a3 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,668 @@ +{ + "appName": "Aves", + "@appName": {}, + "welcomeMessage": "Welcome to Aves", + "@welcomeMessage": {}, + "welcomeAnalyticsToggle": "Allow anonymous analytics and crash reporting (optional)", + "@welcomeAnalyticsToggle": {}, + "welcomeTermsToggle": "I agree to the terms and conditions", + "@welcomeTermsToggle": {}, + + "applyButtonLabel": "APPLY", + "@applyButtonLabel": {}, + "deleteButtonLabel": "DELETE", + "@deleteButtonLabel": {}, + "hideButtonLabel": "HIDE", + "@hideButtonLabel": {}, + "continueButtonLabel": "CONTINUE", + "@continueButtonLabel": {}, + "clearTooltip": "Clear", + "@clearTooltip": {}, + "previousTooltip": "Previous", + "@previousTooltip": {}, + "nextTooltip": "Next", + "@nextTooltip": {}, + + "doubleBackExitMessage": "Tap “back” again to exit.", + "@doubleBackExitMessage": {}, + + "sourceStateLoading": "Loading", + "@sourceStateLoading": {}, + "sourceStateCataloguing": "Cataloguing", + "@sourceStateCataloguing": {}, + "sourceStateLocating": "Locating", + "@sourceStateLocating": {}, + + "chipActionDelete": "Delete", + "@chipActionDelete": {}, + "chipActionGoToAlbumPage": "Show in Albums", + "@chipActionGoToAlbumPage": {}, + "chipActionGoToCountryPage": "Show in Countries", + "@chipActionGoToCountryPage": {}, + "chipActionGoToTagPage": "Show in Tags", + "@chipActionGoToTagPage": {}, + "chipActionHide": "Hide", + "@chipActionHide": {}, + "chipActionPin": "Pin to top", + "@chipActionPin": {}, + "chipActionUnpin": "Unpin from top", + "@chipActionUnpin": {}, + "chipActionRename": "Rename", + "@chipActionRename": {}, + "chipActionSetCover": "Set cover", + "@chipActionSetCover": {}, + + "entryActionDelete": "Delete", + "@entryActionDelete": {}, + "entryActionExport": "Export", + "@entryActionExport": {}, + "entryActionInfo": "Info", + "@entryActionInfo": {}, + "entryActionRename": "Rename", + "@entryActionRename": {}, + "entryActionRotateCCW": "Rotate counterclockwise", + "@entryActionRotateCCW": {}, + "entryActionRotateCW": "Rotate clockwise", + "@entryActionRotateCW": {}, + "entryActionFlip": "Flip horizontally", + "@entryActionFlip": {}, + "entryActionPrint": "Print", + "@entryActionPrint": {}, + "entryActionShare": "Share", + "@entryActionShare": {}, + "entryActionViewSource": "View source", + "@entryActionViewSource": {}, + "entryActionEdit": "Edit with…", + "@entryActionEdit": {}, + "entryActionOpen": "Open with…", + "@entryActionOpen": {}, + "entryActionSetAs": "Set as…", + "@entryActionSetAs": {}, + "entryActionOpenMap": "Show on map…", + "@entryActionOpenMap": {}, + "entryActionAddFavourite": "Add to favourites", + "@entryActionAddFavourite": {}, + "entryActionRemoveFavourite": "Remove from favourites", + "@entryActionRemoveFavourite": {}, + + "filterFavouriteLabel": "Favourite", + "@filterFavouriteLabel": {}, + "filterLocationEmptyLabel": "Unlocated", + "@filterLocationEmptyLabel": {}, + "filterTagEmptyLabel": "Untagged", + "@filterTagEmptyLabel": {}, + "filterTypeAnimatedLabel": "Animated", + "@filterTypeAnimatedLabel": {}, + "filterTypePanoramaLabel": "Panorama", + "@filterTypePanoramaLabel": {}, + "filterTypeSphericalVideoLabel": "360° Video", + "@filterTypeSphericalVideoLabel": {}, + "filterTypeGeotiffLabel": "GeoTIFF", + "@filterTypeGeotiffLabel": {}, + "filterMimeImageLabel": "Image", + "@filterMimeImageLabel": {}, + "filterMimeVideoLabel": "Video", + "@filterMimeVideoLabel": {}, + + "coordinateFormatDms": "DMS", + "@coordinateFormatDms": {}, + "coordinateFormatDecimal": "Decimal degrees", + "@coordinateFormatDecimal": {}, + + "mapStyleGoogleNormal": "Google Maps", + "@mapStyleGoogleNormal": {}, + "mapStyleGoogleHybrid": "Google Maps (Hybrid)", + "@mapStyleGoogleHybrid": {}, + "mapStyleGoogleTerrain": "Google Maps (Terrain)", + "@mapStyleGoogleTerrain": {}, + "mapStyleOsmHot": "Humanitarian OSM", + "@mapStyleOsmHot": {}, + "mapStyleStamenToner": "Stamen Toner", + "@mapStyleStamenToner": {}, + "mapStyleStamenWatercolor": "Stamen Watercolor", + "@mapStyleStamenWatercolor": {}, + + "keepScreenOnNever": "Never", + "@keepScreenOnNever": {}, + "keepScreenOnViewerOnly": "Viewer page only", + "@keepScreenOnViewerOnly": {}, + "keepScreenOnAlways": "Always", + "@keepScreenOnAlways": {}, + + "albumTierPinned": "Pinned", + "@albumTierPinned": {}, + "albumTierSpecial": "Common", + "@albumTierSpecial": {}, + "albumTierApps": "Apps", + "@albumTierApps": {}, + "albumTierRegular": "Others", + "@albumTierRegular": {}, + + "storageVolumeDescriptionFallbackPrimary": "Internal storage", + "@storageVolumeDescriptionFallbackPrimary": {}, + "storageVolumeDescriptionFallbackNonPrimary": "SD card", + "@storageVolumeDescriptionFallbackNonPrimary": {}, + "rootDirectoryDescription": "root directory", + "@rootDirectoryDescription": {}, + "otherDirectoryDescription": "“{name}” directory", + "@otherDirectoryDescription": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "storageAccessDialogTitle": "Storage Access", + "@storageAccessDialogTitle": {}, + "storageAccessDialogMessage": "Please select the {directory} of “{volume}” in the next screen to give this app access to it.", + "@storageAccessDialogMessage": { + "placeholders": { + "directory": { + "type": "String" + }, + "volume": { + "type": "String" + } + } + }, + "restrictedAccessDialogTitle": "Restricted Access", + "@restrictedAccessDialogTitle": {}, + "restrictedAccessDialogMessage": "This app is not allowed to modify files in the {directory} of “{volume}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.", + "@restrictedAccessDialogMessage": { + "placeholders": { + "directory": { + "type": "String" + }, + "volume": { + "type": "String" + } + } + }, + "notEnoughSpaceDialogTitle": "Not Enough Space", + "@notEnoughSpaceDialogTitle": {}, + "notEnoughSpaceDialogMessage": "This operation needs {neededSize} of free space on “{volume}” to complete, but there is only {freeSize} left.", + "@notEnoughSpaceDialogMessage": { + "placeholders": { + "neededSize": { + "type": "String" + }, + "freeSize": { + "type": "String" + }, + "volume": { + "type": "String" + } + } + }, + + "addShortcutDialogLabel": "Shortcut label", + "@addShortcutDialogLabel": {}, + "addShortcutButtonLabel": "ADD", + "@addShortcutButtonLabel": {}, + + "noMatchingAppDialogTitle": "No Matching App", + "@noMatchingAppDialogTitle": {}, + "noMatchingAppDialogMessage": "There are no apps that can handle this.", + "@noMatchingAppDialogMessage": {}, + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}", + "@deleteEntriesConfirmationDialogMessage": { + "placeholders": { + "count": {} + } + }, + + "setCoverDialogTitle": "Set Cover", + "@setCoverDialogTitle": {}, + "setCoverDialogLatest": "Latest item", + "@setCoverDialogLatest": {}, + "setCoverDialogCustom": "Custom", + "@setCoverDialogCustom": {}, + + "hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?", + "@hideFilterConfirmationDialogMessage": {}, + + "newAlbumDialogTitle": "New Album", + "@newAlbumDialogTitle": {}, + "newAlbumDialogNameLabel": "Album name", + "@newAlbumDialogNameLabel": {}, + "newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists", + "@newAlbumDialogNameLabelAlreadyExistsHelper": {}, + "newAlbumDialogStorageLabel": "Storage:", + "@newAlbumDialogStorageLabel": {}, + + "renameAlbumDialogLabel": "New name", + "@renameAlbumDialogLabel": {}, + "renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", + "@renameAlbumDialogLabelAlreadyExistsHelper": {}, + + "deleteAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}", + "@deleteAlbumConfirmationDialogMessage": { + "placeholders": { + "count": {} + } + }, + + "renameEntryDialogLabel": "New name", + "@renameEntryDialogLabel": {}, + + "genericSuccessFeedback": "Done!", + "@genericSuccessFeedback": {}, + "genericFailureFeedback": "Failed", + "@genericFailureFeedback": {}, + + "menuActionSort": "Sort", + "@menuActionSort": {}, + "menuActionGroup": "Group", + "@menuActionGroup": {}, + "menuActionStats": "Stats", + "@menuActionStats": {}, + + "aboutPageTitle": "About", + "@aboutPageTitle": {}, + "aboutFlutter": "Flutter", + "@aboutFlutter": {}, + "aboutUpdate": "New Version Available", + "@aboutUpdate": {}, + "aboutUpdateLinks1": "A new version of Aves is available on", + "@aboutUpdateLinks1": {}, + "aboutUpdateLinks2": "and", + "@aboutUpdateLinks2": {}, + "aboutUpdateLinks3": ".", + "@aboutUpdateLinks3": {}, + "aboutUpdateGithub": "Github", + "@aboutUpdateGithub": {}, + "aboutUpdateGooglePlay": "Google Play", + "@aboutUpdateGooglePlay": {}, + "aboutCredits": "Credits", + "@aboutCredits": {}, + "aboutCreditsWorldAtlas1": "This app uses a TopoJSON file from", + "@aboutCreditsWorldAtlas1": {}, + "aboutCreditsWorldAtlas2": "under ISC License.", + "@aboutCreditsWorldAtlas2": {}, + "aboutLicenses": "Open-Source Licenses", + "@aboutLicenses": {}, + "aboutLicensesBanner": "This app uses the following open-source packages and libraries.", + "@aboutLicensesBanner": {}, + "aboutLicensesSortTooltip": "Sort", + "@aboutLicensesSortTooltip": {}, + "aboutLicensesSortByName": "Sort by name", + "@aboutLicensesSortByName": {}, + "aboutLicensesSortByLicense": "Sort by license", + "@aboutLicensesSortByLicense": {}, + "aboutLicensesAndroidLibraries": "Android Libraries", + "@aboutLicensesAndroidLibraries": {}, + "aboutLicensesFlutterPlugins": "Flutter Plugins", + "@aboutLicensesFlutterPlugins": {}, + "aboutLicensesFlutterPackages": "Flutter Packages", + "@aboutLicensesFlutterPackages": {}, + "aboutLicensesDartPackages": "Dart Packages", + "@aboutLicensesDartPackages": {}, + "aboutLicensesShowAllButtonLabel": "SHOW ALL LICENSES", + "@aboutLicensesShowAllButtonLabel": {}, + + "collectionPageTitle": "Collection", + "@collectionPageTitle": {}, + "collectionPickPageTitle": "Pick", + "@collectionPickPageTitle": {}, + "collectionSelectionPageTitle": "{count, plural, =0{Select items} =1{1 item} other{{count} items}}", + "@collectionSelectionPageTitle": { + "placeholders": { + "count": {} + } + }, + + "collectionActionAddShortcut": "Add shortcut", + "@collectionActionAddShortcut": {}, + "collectionActionSelect": "Select", + "@collectionActionSelect": {}, + "collectionActionSelectAll": "Select all", + "@collectionActionSelectAll": {}, + "collectionActionSelectNone": "Select none", + "@collectionActionSelectNone": {}, + "collectionActionCopy": "Copy to album", + "@collectionActionCopy": {}, + "collectionActionMove": "Move to album", + "@collectionActionMove": {}, + "collectionActionRefreshMetadata": "Refresh metadata", + "@collectionActionRefreshMetadata": {}, + + "collectionSortTitle": "Sort", + "@collectionSortTitle": {}, + "collectionSortDate": "By date", + "@collectionSortDate": {}, + "collectionSortSize": "By size", + "@collectionSortSize": {}, + "collectionSortName": "By album & file name", + "@collectionSortName": {}, + + "collectionGroupTitle": "Group", + "@collectionGroupTitle": {}, + "collectionGroupAlbum": "By album", + "@collectionGroupAlbum": {}, + "collectionGroupMonth": "By month", + "@collectionGroupMonth": {}, + "collectionGroupDay": "By day", + "@collectionGroupDay": {}, + "collectionGroupNone": "Do not group", + "@collectionGroupNone": {}, + + "sectionUnknown": "Unknown", + "@sectionUnknown": {}, + "dateToday": "Today", + "@dateToday": {}, + "dateYesterday": "Yesterday", + "@dateYesterday": {}, + "dateThisMonth": "This month", + "@dateThisMonth": {}, + "errorUnsupportedMimeType": "{mimeType} not supported", + "@errorUnsupportedMimeType": { + "placeholders": { + "mimeType": { + "type": "String" + } + } + }, + "collectionDeleteFailureFeedback": "{count, plural, =1{Failed to delete 1 item} other{Failed to delete {count} items}}", + "@collectionDeleteFailureFeedback": { + "placeholders": { + "count": {} + } + }, + "collectionCopyFailureFeedback": "{count, plural, =1{Failed to copy 1 item} other{Failed to copy {count} items}}", + "@collectionCopyFailureFeedback": { + "placeholders": { + "count": {} + } + }, + "collectionMoveFailureFeedback": "{count, plural, =1{Failed to move 1 item} other{Failed to move {count} items}}", + "@collectionMoveFailureFeedback": { + "placeholders": { + "count": {} + } + }, + "collectionExportFailureFeedback": "{count, plural, =1{Failed to export 1 page} other{Failed to export {count} pages}}", + "@collectionExportFailureFeedback": { + "placeholders": { + "count": {} + } + }, + "collectionCopySuccessFeedback": "{count, plural, =1{Copied 1 item} other{Copied {count} items}}", + "@collectionCopySuccessFeedback": { + "placeholders": { + "count": {} + } + }, + "collectionMoveSuccessFeedback": "{count, plural, =1{Moved 1 item} other{Moved {count} items}}", + "@collectionMoveSuccessFeedback": { + "placeholders": { + "count": {} + } + }, + + "collectionEmptyFavourites": "No favourites", + "@collectionEmptyFavourites": {}, + "collectionEmptyVideos": "No videos", + "@collectionEmptyVideos": {}, + "collectionEmptyImages": "No images", + "@collectionEmptyImages": {}, + + "collectionSelectSectionTooltip": "Select section", + "@collectionSelectSectionTooltip": {}, + "collectionDeselectSectionTooltip": "Deselect section", + "@collectionDeselectSectionTooltip": {}, + + "drawerCollectionAll": "All collection", + "@drawerCollectionAll": {}, + "drawerCollectionVideos": "Videos", + "@drawerCollectionVideos": {}, + "drawerCollectionFavourites": "Favourites", + "@drawerCollectionFavourites": {}, + + "chipSortTitle": "Sort", + "@chipSortTitle": {}, + "chipSortDate": "By date", + "@chipSortDate": {}, + "chipSortName": "By name", + "@chipSortName": {}, + "chipSortCount": "By item count", + "@chipSortCount": {}, + + "albumGroupTitle": "Group", + "@albumGroupTitle": {}, + "albumGroupTier": "By tier", + "@albumGroupTier": {}, + "albumGroupVolume": "By storage volume", + "@albumGroupVolume": {}, + "albumGroupNone": "Do not group", + "@albumGroupNone": {}, + + "albumPickPageTitleCopy": "Copy to Album", + "@albumPickPageTitleCopy": {}, + "albumPickPageTitleExport": "Export to Album", + "@albumPickPageTitleExport": {}, + "albumPickPageTitleMove": "Move to Album", + "@albumPickPageTitleMove": {}, + + "albumPageTitle": "Albums", + "@albumPageTitle": {}, + "albumEmpty": "No albums", + "@albumEmpty": {}, + "createAlbumTooltip": "Create album", + "@createAlbumTooltip": {}, + "createAlbumButtonLabel": "CREATE", + "@createAlbumButtonLabel": {}, + + "countryPageTitle": "Countries", + "@countryPageTitle": {}, + "countryEmpty": "No countries", + "@countryEmpty": {}, + + "tagPageTitle": "Tags", + "@tagPageTitle": {}, + "tagEmpty": "No tags", + "@tagEmpty": {}, + + "searchCollectionFieldHint": "Search collection", + "@searchCollectionFieldHint": {}, + "searchSectionRecent": "Recent", + "@searchSectionRecent": {}, + "searchSectionAlbums": "Albums", + "@searchSectionAlbums": {}, + "searchSectionCountries": "Countries", + "@searchSectionCountries": {}, + "searchSectionPlaces": "Places", + "@searchSectionPlaces": {}, + "searchSectionTags": "Tags", + "@searchSectionTags": {}, + + "settingsPageTitle": "Settings", + "@settingsPageTitle": {}, + "settingsSystemDefault": "System", + "@settingsSystemDefault": {}, + + "settingsSectionNavigation": "Navigation", + "@settingsSectionNavigation": {}, + "settingsHome": "Home", + "@settingsHome": {}, + "settingsDoubleBackExit": "Tap “back” twice to exit", + "@settingsDoubleBackExit": {}, + + "settingsSectionDisplay": "Display", + "@settingsSectionDisplay": {}, + "settingsLanguage": "Language", + "@settingsLanguage": {}, + "settingsKeepScreenOnTile": "Keep screen on", + "@settingsKeepScreenOnTile": {}, + "settingsKeepScreenOnTitle": "Keep Screen On", + "@settingsKeepScreenOnTitle": {}, + "settingsRasterImageBackground": "Raster image background", + "@settingsRasterImageBackground": {}, + "settingsVectorImageBackground": "Vector image background", + "@settingsVectorImageBackground": {}, + "settingsCoordinateFormatTile": "Coordinate format", + "@settingsCoordinateFormatTile": {}, + "settingsCoordinateFormatTitle": "Coordinate Format", + "@settingsCoordinateFormatTitle": {}, + + "settingsSectionThumbnails": "Thumbnails", + "@settingsSectionThumbnails": {}, + "settingsThumbnailShowLocationIcon": "Show location icon", + "@settingsThumbnailShowLocationIcon": {}, + "settingsThumbnailShowRawIcon": "Show raw icon", + "@settingsThumbnailShowRawIcon": {}, + "settingsThumbnailShowVideoDuration": "Show video duration", + "@settingsThumbnailShowVideoDuration": {}, + + "settingsSectionViewer": "Viewer", + "@settingsSectionViewer": {}, + "settingsViewerShowMinimap": "Show minimap", + "@settingsViewerShowMinimap": {}, + "settingsViewerShowInformation": "Show information", + "@settingsViewerShowInformation": {}, + "settingsViewerShowInformationSubtitle": "Show title, date, location, etc.", + "@settingsViewerShowInformationSubtitle": {}, + "settingsViewerShowShootingDetails": "Show shooting details", + "@settingsViewerShowShootingDetails": {}, + + "settingsSectionSearch": "Search", + "@settingsSectionSearch": {}, + "settingsSaveSearchHistory": "Save search history", + "@settingsSaveSearchHistory": {}, + + "settingsSectionPrivacy": "Privacy", + "@settingsSectionPrivacy": {}, + "settingsEnableAnalytics": "Allow anonymous analytics and crash reporting", + "@settingsEnableAnalytics": {}, + + "settingsHiddenFiltersTile": "Hidden filters", + "@settingsHiddenFiltersTile": {}, + "settingsHiddenFiltersTitle": "Hidden Filters", + "@settingsHiddenFiltersTitle": {}, + "settingsHiddenFiltersBanner": "Photos and videos matching hidden filters will not appear in your collection.", + "@settingsHiddenFiltersBanner": {}, + "settingsHiddenFiltersEmpty": "No hidden filters", + "@settingsHiddenFiltersEmpty": {}, + + "settingsStorageAccessTile": "Storage access", + "@settingsStorageAccessTile": {}, + "settingsStorageAccessTitle": "Storage Access", + "@settingsStorageAccessTitle": {}, + "settingsStorageAccessBanner": "Some directories require an explicit access grant to modify files in them. You can review here directories to which you previously gave access.", + "@settingsStorageAccessBanner": {}, + "settingsStorageAccessEmpty": "No access grants", + "@settingsStorageAccessEmpty": {}, + "settingsStorageAccessRevokeTooltip": "Revoke", + "@settingsStorageAccessRevokeTooltip": {}, + + "statsPageTitle": "Stats", + "@statsPageTitle": {}, + "statsImage": "{count, plural, =1{image} other{images}}", + "@statsImage": { + "placeholders": { + "count": {} + } + }, + "statsVideo": "{count, plural, =1{video} other{videos}}", + "@statsVideo": { + "placeholders": { + "count": {} + } + }, + "statsWithGps": "{count, plural, =1{1 item with location} other{{count} items with location}}", + "@statsWithGps": { + "placeholders": { + "count": {} + } + }, + "statsTopCountries": "Top Countries", + "@statsTopCountries": {}, + "statsTopPlaces": "Top Places", + "@statsTopPlaces": {}, + "statsTopTags": "Top Tags", + "@statsTopTags": {}, + + "viewerOpenPanoramaButtonLabel": "OPEN PANORAMA", + "@viewerOpenPanoramaButtonLabel": {}, + "viewerOpenTooltip": "Open", + "@viewerOpenTooltip": {}, + "viewerPauseTooltip": "Pause", + "@viewerPauseTooltip": {}, + "viewerPlayTooltip": "Play", + "@viewerPlayTooltip": {}, + "viewerErrorUnknown": "Oops!", + "@viewerErrorUnknown": {}, + "viewerErrorDoesNotExist": "The file no longer exists.", + "@viewerErrorDoesNotExist": {}, + + "viewerInfoPageTitle": "Info", + "@viewerInfoPageTitle": {}, + "viewerInfoBackToViewerTooltip": "Back to viewer", + "@viewerInfoBackToViewerTooltip": {}, + + "viewerInfoUnknown": "unknown", + "@viewerInfoUnknown": {}, + "viewerInfoLabelTitle": "Title", + "@viewerInfoLabelTitle": {}, + "viewerInfoLabelDate": "Date", + "@viewerInfoLabelDate": {}, + "viewerInfoLabelResolution": "Resolution", + "@viewerInfoLabelResolution": {}, + "viewerInfoLabelSize": "Size", + "@viewerInfoLabelSize": {}, + "viewerInfoLabelUri": "URI", + "@viewerInfoLabelUri": {}, + "viewerInfoLabelPath": "Path", + "@viewerInfoLabelPath": {}, + "viewerInfoLabelDuration": "Duration", + "@viewerInfoLabelDuration": {}, + "viewerInfoLabelOwner": "Owned by", + "@viewerInfoLabelOwner": {}, + "viewerInfoLabelCoordinates": "Coordinates", + "@viewerInfoLabelCoordinates": {}, + "viewerInfoLabelAddress": "Address", + "@viewerInfoLabelAddress": {}, + + "viewerInfoMapStyleTitle": "Map Style", + "@viewerInfoMapStyleTitle": {}, + "viewerInfoMapStyleTooltip": "Select map style", + "@viewerInfoMapStyleTooltip": {}, + "viewerInfoMapZoomInTooltip": "Zoom in", + "@viewerInfoMapZoomInTooltip": {}, + "viewerInfoMapZoomOutTooltip": "Zoom out", + "@viewerInfoMapZoomOutTooltip": {}, + "mapAttributionOsmHot": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [HOT](https://www.hotosm.org/) • Hosted by [OSM France](https://openstreetmap.fr/)", + "@mapAttributionOsmHot": {}, + "mapAttributionStamen": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)", + "@mapAttributionStamen": {}, + + "viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data", + "@viewerInfoOpenEmbeddedFailureFeedback": {}, + "viewerInfoOpenLinkText": "Open", + "@viewerInfoOpenLinkText": {}, + "viewerInfoViewXmlLinkText": "View XML", + "@viewerInfoViewXmlLinkText": {}, + + "viewerInfoSearchFieldLabel": "Search metadata", + "@viewerInfoSearchFieldLabel": {}, + "viewerInfoSearchEmpty": "No matching keys", + "@viewerInfoSearchEmpty": {}, + "viewerInfoSearchSuggestionDate": "Date & time", + "@viewerInfoSearchSuggestionDate": {}, + "viewerInfoSearchSuggestionDescription": "Description", + "@viewerInfoSearchSuggestionDescription": {}, + "viewerInfoSearchSuggestionDimensions": "Dimensions", + "@viewerInfoSearchSuggestionDimensions": {}, + "viewerInfoSearchSuggestionResolution": "Resolution", + "@viewerInfoSearchSuggestionResolution": {}, + "viewerInfoSearchSuggestionRights": "Rights", + "@viewerInfoSearchSuggestionRights": {}, + + "panoramaEnableSensorControl": "Enable sensor control", + "@panoramaEnableSensorControl": {}, + "panoramaDisableSensorControl": "Disable sensor control", + "@panoramaDisableSensorControl": {}, + + "sourceViewerPageTitle": "Source", + "@sourceViewerPageTitle": {} +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 000000000..641fe495d --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,318 @@ +{ + "appName": "아베스", + "welcomeMessage": "아베스 사용을 환영합니다", + "welcomeAnalyticsToggle": "진단 데이터를 보내는 것에 동의합니다 (선택)", + "welcomeTermsToggle": "이용약관에 동의합니다", + + "applyButtonLabel": "확인", + "deleteButtonLabel": "삭제", + "hideButtonLabel": "숨기기", + "continueButtonLabel": "다음", + "clearTooltip": "초기화", + "previousTooltip": "이전", + "nextTooltip": "다음", + + "doubleBackExitMessage": "종료하려면 한번 더 누르세요.", + + "sourceStateLoading": "로딩 중", + "sourceStateCataloguing": "분석 중", + "sourceStateLocating": "장소 찾는 중", + + "chipActionDelete": "삭제", + "chipActionGoToAlbumPage": "앨범 페이지에서 보기", + "chipActionGoToCountryPage": "국가 페이지에서 보기", + "chipActionGoToTagPage": "태그 페이지에서 보기", + "chipActionHide": "숨기기", + "chipActionPin": "고정", + "chipActionUnpin": "고정 해제", + "chipActionRename": "이름 변경", + "chipActionSetCover": "대표 이미지 변경", + + "entryActionDelete": "삭제", + "entryActionExport": "내보내기", + "entryActionInfo": "상세정보", + "entryActionRename": "이름 변경", + "entryActionRotateCCW": "좌회전", + "entryActionRotateCW": "우회전", + "entryActionFlip": "좌우 뒤집기", + "entryActionPrint": "인쇄", + "entryActionShare": "공유", + "entryActionViewSource": "소스 코드 보기", + "entryActionEdit": "편집…", + "entryActionOpen": "다른 앱에서 열기…", + "entryActionSetAs": "다음 용도로 사용…", + "entryActionOpenMap": "지도에서 보기…", + "entryActionAddFavourite": "즐겨찾기에 추가", + "entryActionRemoveFavourite": "즐겨찾기에서 삭제", + + "filterFavouriteLabel": "즐겨찾기", + "filterLocationEmptyLabel": "장소 없음", + "filterTagEmptyLabel": "태그 없음", + "filterTypeAnimatedLabel": "애니메이션", + "filterTypePanoramaLabel": "파노라마", + "filterTypeSphericalVideoLabel": "360° 동영상", + "filterTypeGeotiffLabel": "GeoTIFF", + "filterMimeImageLabel": "사진", + "filterMimeVideoLabel": "동영상", + + "coordinateFormatDms": "도분초", + "coordinateFormatDecimal": "소수점", + + "mapStyleGoogleNormal": "구글 지도", + "mapStyleGoogleHybrid": "구글 지도 (위성)", + "mapStyleGoogleTerrain": "구글 지도 (지형)", + "mapStyleOsmHot": "Humanitarian OSM", + "mapStyleStamenToner": "Stamen 토너", + "mapStyleStamenWatercolor": "Stamen 수채화", + + "keepScreenOnNever": "자동 꺼짐", + "keepScreenOnViewerOnly": "뷰어 이용 시 작동", + "keepScreenOnAlways": "항상 켜짐", + + "albumTierPinned": "고정", + "albumTierSpecial": "기본", + "albumTierApps": "앱", + "albumTierRegular": "일반", + + "storageVolumeDescriptionFallbackPrimary": "내장 메모리", + "storageVolumeDescriptionFallbackNonPrimary": "SD 카드", + "rootDirectoryDescription": "루트 폴더", + "otherDirectoryDescription": "“{name}” 폴더", + "storageAccessDialogTitle": "저장공간 접근", + "storageAccessDialogMessage": "파일에 접근하도록 다음 화면에서 “{volume}”의 {directory}를 선택하세요.", + "restrictedAccessDialogTitle": "접근 제한", + "restrictedAccessDialogMessage": "“{volume}”의 {directory}에 있는 파일의 접근이 제한됩니다.\n\n기본으로 설치된 갤러리나 파일 관리 앱을 사용해서 다른 폴더로 파일을 이동하세요.", + "notEnoughSpaceDialogTitle": "저장공간 부족", + "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", + + "addShortcutDialogLabel": "바로가기 라벨", + "addShortcutButtonLabel": "추가", + + "noMatchingAppDialogTitle": "처리할 앱 없음", + "noMatchingAppDialogMessage": "이 작업을 처리할 수 있는 앱이 없습니다.", + + "deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}", + + "setCoverDialogTitle": "대표 이미지 변경", + "setCoverDialogLatest": "최근 항목", + "setCoverDialogCustom": "직접 설정", + + "hideFilterConfirmationDialogMessage": "이 필터에 맞는 사진과 동영상이 보이지 않을 것입니다. “개인정보 보호” 설정을 수정하면 다시 보일 수 있습니다.\n\n이 필터를 숨기시겠습니까?", + + "newAlbumDialogTitle": "새 앨범 만들기", + "newAlbumDialogNameLabel": "앨범 이름", + "newAlbumDialogNameLabelAlreadyExistsHelper": "사용 중인 이름입니다", + "newAlbumDialogStorageLabel": "저장공간:", + + "renameAlbumDialogLabel": "앨범 이름", + "renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다", + + "deleteAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", + + "renameEntryDialogLabel": "이름", + + "genericSuccessFeedback": "정상 처리됐습니다", + "genericFailureFeedback": "오류가 발생했습니다", + + "menuActionSort": "정렬", + "menuActionGroup": "묶음", + "menuActionStats": "통계", + + "aboutPageTitle": "앱 정보", + "aboutFlutter": "플러터", + "aboutUpdate": "업데이트 사용 가능", + "aboutUpdateLinks1": "앱의 최신 버전을", + "aboutUpdateLinks2": "와", + "aboutUpdateLinks3": "에서 다운로드 사용 가능합니다.", + "aboutUpdateGithub": "깃허브", + "aboutUpdateGooglePlay": "구글 플레이", + "aboutCredits": "크레딧", + "aboutCreditsWorldAtlas1": "이 앱은", + "aboutCreditsWorldAtlas2": "의 TopoJSON 파일(ISC 라이선스)을 이용합니다.", + "aboutLicenses": "오픈 소스 라이선스", + "aboutLicensesBanner": "이 앱은 다음의 오픈 소스 패키지와 라이브러리를 이용합니다.", + "aboutLicensesSortTooltip": "정렬", + "aboutLicensesSortByName": "이름순 정렬", + "aboutLicensesSortByLicense": "라이선스순 정렬", + "aboutLicensesAndroidLibraries": "안드로이드 라이브러리", + "aboutLicensesFlutterPlugins": "플러터 플러그인", + "aboutLicensesFlutterPackages": "플러터 패키지", + "aboutLicensesDartPackages": "다트 패키지", + "aboutLicensesShowAllButtonLabel": "라이선스 모두 보기", + + "collectionPageTitle": "미디어", + "collectionPickPageTitle": "항목 선택", + "collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}", + + "collectionActionAddShortcut": "홈 화면에 추가", + "collectionActionSelect": "선택", + "collectionActionSelectAll": "모두 선택", + "collectionActionSelectNone": "모두 해제", + "collectionActionCopy": "앨범으로 복사", + "collectionActionMove": "앨범으로 이동", + "collectionActionRefreshMetadata": "새로 분석", + + "collectionSortTitle": "정렬", + "collectionSortDate": "날짜", + "collectionSortSize": "크기", + "collectionSortName": "이름", + + "collectionGroupTitle": "묶음", + "collectionGroupAlbum": "앨범별로", + "collectionGroupMonth": "월별로", + "collectionGroupDay": "날짜별로", + "collectionGroupNone": "묶음 없음", + + "sectionUnknown": "없음", + "dateToday": "오늘", + "dateYesterday": "어제", + "dateThisMonth": "이번 달", + "errorUnsupportedMimeType": "{mimeType} 지원되지 않음", + "collectionDeleteFailureFeedback": "{count, plural, other{항목 {count}개를 삭제하지 못했습니다}}", + "collectionCopyFailureFeedback": "{count, plural, other{항목 {count}개를 복사하지 못했습니다}}", + "collectionMoveFailureFeedback": "{count, plural, other{항목 {count}개를 이동하지 못했습니다}}", + "collectionExportFailureFeedback": "{count, plural, other{항목 {count}개를 내보내지 못했습니다}}", + "collectionCopySuccessFeedback": "{count, plural, other{항목 {count}개를 복사했습니다}}", + "collectionMoveSuccessFeedback": "{count, plural, other{항목 {count}개를 이동했습니다}}", + + "collectionEmptyFavourites": "즐겨찾기가 없습니다", + "collectionEmptyVideos": "동영상이 없습니다", + "collectionEmptyImages": "사진이 없습니다", + + "collectionSelectSectionTooltip": "묶음 선택", + "collectionDeselectSectionTooltip": "묶음 선택 해제", + + "drawerCollectionAll": "모든 미디어", + "drawerCollectionVideos": "동영상", + "drawerCollectionFavourites": "즐겨찾기", + + "chipSortTitle": "정렬", + "chipSortDate": "날짜", + "chipSortName": "이름", + "chipSortCount": "항목수", + + "albumGroupTitle": "묶음", + "albumGroupTier": "단계별로", + "albumGroupVolume": "저장공간별로", + "albumGroupNone": "묶음 없음", + + "albumPickPageTitleCopy": "앨범으로 복사", + "albumPickPageTitleExport": "앨범으로 내보내기", + "albumPickPageTitleMove": "앨범으로 이동", + + "albumPageTitle": "앨범", + "albumEmpty": "앨범이 없습니다", + "createAlbumTooltip": "새 앨범 만들기", + "createAlbumButtonLabel": "추가", + + "countryPageTitle": "국가", + "countryEmpty": "국가가 없습니다", + + "tagPageTitle": "태그", + "tagEmpty": "태그가 없습니다", + + "searchCollectionFieldHint": "미디어 검색", + "searchSectionRecent": "최근 검색기록", + "searchSectionAlbums": "앨범", + "searchSectionCountries": "국가", + "searchSectionPlaces": "장소", + "searchSectionTags": "태그", + + "settingsPageTitle": "설정", + "settingsSystemDefault": "시스템", + + "settingsSectionNavigation": "탐색", + "settingsHome": "홈", + "settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기", + + "settingsSectionDisplay": "디스플레이", + "settingsLanguage": "언어", + "settingsKeepScreenOnTile": "화면 자동 꺼짐 방지", + "settingsKeepScreenOnTitle": "화면 자동 꺼짐 방지", + "settingsRasterImageBackground": "래스터 그래픽스 배경", + "settingsVectorImageBackground": "벡터 그래픽스 배경", + "settingsCoordinateFormatTile": "좌표 표현", + "settingsCoordinateFormatTitle": "좌표 표현", + + "settingsSectionThumbnails": "섬네일", + "settingsThumbnailShowLocationIcon": "위치 아이콘 표시", + "settingsThumbnailShowRawIcon": "Raw 아이콘 표시", + "settingsThumbnailShowVideoDuration": "동영상 길이 표시", + + "settingsSectionViewer": "뷰어", + "settingsViewerShowMinimap": "미니맵 표시", + "settingsViewerShowInformation": "상세 정보 표시", + "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", + "settingsViewerShowShootingDetails": "촬영 정보 표시", + + "settingsSectionSearch": "검색", + "settingsSaveSearchHistory": "검색기록", + + "settingsSectionPrivacy": "개인정보 보호", + "settingsEnableAnalytics": "진단 데이터 보내기", + + "settingsHiddenFiltersTile": "숨겨진 필터", + "settingsHiddenFiltersTitle": "숨겨진 필터", + "settingsHiddenFiltersBanner": "이 필터에 맞는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.", + "settingsHiddenFiltersEmpty": "숨겨진 필터가 없습니다", + + "settingsStorageAccessTile": "저장공간 접근", + "settingsStorageAccessTitle": "저장공간 접근", + "settingsStorageAccessBanner": "어떤 폴더는 사용자의 허용을 받아야만 앱이 파일에 접근이 가능합니다. 이 화면에 허용을 받은 폴더를 확인할 수 있으며 원하지 않으면 취소할 수 있습니다.", + "settingsStorageAccessEmpty": "접근 허용이 없습니다", + "settingsStorageAccessRevokeTooltip": "취소", + + "statsPageTitle": "통계", + "statsImage": "{count, plural, other{사진}}", + "statsVideo": "{count, plural, other{동영상}}", + "statsWithGps": "{count, plural, other{{count}개 위치가 있음}}", + "statsTopCountries": "국가 랭킹", + "statsTopPlaces": "장소 랭킹", + "statsTopTags": "태그 랭킹", + + "viewerOpenPanoramaButtonLabel": "파노라마 열기", + "viewerOpenTooltip": "열기", + "viewerPauseTooltip": "일시정지", + "viewerPlayTooltip": "재생", + "viewerErrorUnknown": "아이구!", + "viewerErrorDoesNotExist": "파일이 존재하지 않습니다.", + + "viewerInfoPageTitle": "상세정보", + "viewerInfoBackToViewerTooltip": "뷰어로", + + "viewerInfoUnknown": "알 수 없음", + "viewerInfoLabelTitle": "제목", + "viewerInfoLabelDate": "날짜", + "viewerInfoLabelResolution": "해상도", + "viewerInfoLabelSize": "크기", + "viewerInfoLabelUri": "URI", + "viewerInfoLabelPath": "경로", + "viewerInfoLabelDuration": "길이", + "viewerInfoLabelOwner": "패키지", + "viewerInfoLabelCoordinates": "좌표", + "viewerInfoLabelAddress": "주소", + + "viewerInfoMapStyleTitle": "지도 유형", + "viewerInfoMapStyleTooltip": "지도 유형 선택", + "viewerInfoMapZoomInTooltip": "확대", + "viewerInfoMapZoomOutTooltip": "축소", + "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)", + + "viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류", + "viewerInfoOpenLinkText": "열기", + "viewerInfoViewXmlLinkText": "XML 보기", + + "viewerInfoSearchFieldLabel": "메타데이터 검색", + "viewerInfoSearchEmpty": "결과가 없습니다", + "viewerInfoSearchSuggestionDate": "날짜 및 시간", + "viewerInfoSearchSuggestionDescription": "설명", + "viewerInfoSearchSuggestionDimensions": "크기", + "viewerInfoSearchSuggestionResolution": "해상도", + "viewerInfoSearchSuggestionRights": "권리", + + "panoramaEnableSensorControl": "센서 제어 활성화", + "panoramaDisableSensorControl": "센서 제어 비활성화", + + "sourceViewerPageTitle": "소스 코드" +} diff --git a/lib/main.dart b/lib/main.dart index a9bad4218..13fbebc61 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,18 @@ import 'dart:isolate'; import 'dart:ui'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; @@ -19,6 +23,8 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:provider/provider.dart'; @@ -37,16 +43,13 @@ void main() { runApp(AvesApp()); } -enum AppMode { main, pick, view } - class AvesApp extends StatefulWidget { - static AppMode mode; - @override _AvesAppState createState() => _AvesAppState(); } class _AvesAppState extends State { + final ValueNotifier appModeNotifier = ValueNotifier(AppMode.main); Future _appSetup; final _mediaStoreSource = MediaStoreSource(); final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); @@ -59,49 +62,12 @@ class _AvesAppState extends State { final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent'); final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); - static const accentColor = Colors.indigoAccent; - - static final darkTheme = ThemeData( - brightness: Brightness.dark, - accentColor: accentColor, - scaffoldBackgroundColor: Colors.grey[900], - buttonColor: accentColor, - dialogBackgroundColor: Colors.grey[850], - toggleableActiveColor: accentColor, - tooltipTheme: TooltipThemeData( - verticalOffset: 32, - ), - appBarTheme: AppBarTheme( - textTheme: TextTheme( - headline6: TextStyle( - fontSize: 20, - fontWeight: FontWeight.normal, - fontFeatures: [FontFeature.enable('smcp')], - ), - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - primary: accentColor, - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - primary: accentColor, - ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - primary: Colors.white, - ), - ), - ); - Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage(); @override void initState() { super.initState(); + initPlatformServices(); _appSetup = _setup(); _contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map)); @@ -113,27 +79,41 @@ class _AvesAppState extends State { // so it can be used during navigation transitions return ChangeNotifierProvider.value( value: settings, - child: Provider.value( - value: _mediaStoreSource, - child: HighlightInfoProvider( - child: OverlaySupport( - child: FutureBuilder( - future: _appSetup, - builder: (context, snapshot) { - final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) - ? getFirstPage() - : Scaffold( - body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), - ); - return MaterialApp( - navigatorKey: _navigatorKey, - home: home, - navigatorObservers: _navigatorObservers, - title: 'Aves', - darkTheme: darkTheme, - themeMode: ThemeMode.dark, - ); - }, + child: ListenableProvider>.value( + value: appModeNotifier, + child: Provider.value( + value: _mediaStoreSource, + child: HighlightInfoProvider( + child: OverlaySupport( + child: FutureBuilder( + future: _appSetup, + builder: (context, snapshot) { + final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done; + final home = initialized + ? getFirstPage() + : Scaffold( + body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox(), + ); + return Selector( + selector: (context, s) => s.locale, + builder: (context, settingsLocale, child) { + return MaterialApp( + navigatorKey: _navigatorKey, + home: home, + navigatorObservers: _navigatorObservers, + onGenerateTitle: (context) => context.l10n.appName, + darkTheme: Themes.darkTheme, + themeMode: ThemeMode.dark, + locale: settingsLocale, + localizationsDelegates: [ + ...AppLocalizations.localizationsDelegates, + LocaleNamesLocalizationsDelegate(), + ], + supportedLocales: AppLocalizations.supportedLocales, + ); + }); + }, + ), ), ), ), @@ -183,7 +163,7 @@ class _AvesAppState extends State { debugPrint('$runtimeType onNewIntent with intentData=$intentData'); // do not reset when relaunching the app - if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; + if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; FirebaseCrashlytics.instance.log('New intent'); _navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute( diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index 27ea9d6c2..412f745a1 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -1,10 +1,10 @@ import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; enum ChipSetAction { group, sort, - refresh, stats, } @@ -14,30 +14,33 @@ enum ChipAction { pin, unpin, rename, + setCover, goToAlbumPage, goToCountryPage, goToTagPage, } extension ExtraChipAction on ChipAction { - String getText() { + String getText(BuildContext context) { switch (this) { case ChipAction.delete: - return 'Delete'; + return context.l10n.chipActionDelete; case ChipAction.goToAlbumPage: - return 'Show in Albums'; + return context.l10n.chipActionGoToAlbumPage; case ChipAction.goToCountryPage: - return 'Show in Countries'; + return context.l10n.chipActionGoToCountryPage; case ChipAction.goToTagPage: - return 'Show in Tags'; + return context.l10n.chipActionGoToTagPage; case ChipAction.hide: - return 'Hide'; + return context.l10n.chipActionHide; case ChipAction.pin: - return 'Pin to top'; + return context.l10n.chipActionPin; case ChipAction.unpin: - return 'Unpin from top'; + return context.l10n.chipActionUnpin; case ChipAction.rename: - return 'Rename'; + return context.l10n.chipActionRename; + case ChipAction.setCover: + return context.l10n.chipActionSetCover; } return null; } @@ -59,6 +62,8 @@ extension ExtraChipAction on ChipAction { return AIcons.pin; case ChipAction.rename: return AIcons.rename; + case ChipAction.setCover: + return AIcons.setCover; } return null; } diff --git a/lib/model/actions/collection_actions.dart b/lib/model/actions/collection_actions.dart index 531c8e5f9..360fd00b9 100644 --- a/lib/model/actions/collection_actions.dart +++ b/lib/model/actions/collection_actions.dart @@ -2,7 +2,6 @@ enum CollectionAction { addShortcut, sort, group, - refresh, select, selectAll, selectNone, diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index 5cd6f62c3..ad47ac452 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -1,4 +1,5 @@ import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; enum EntryAction { @@ -46,41 +47,41 @@ class EntryActions { } extension ExtraEntryAction on EntryAction { - String getText() { + String getText(BuildContext context) { switch (this) { // in app actions case EntryAction.toggleFavourite: // different data depending on toggle state return null; case EntryAction.delete: - return 'Delete'; + return context.l10n.entryActionDelete; case EntryAction.export: - return 'Export'; + return context.l10n.entryActionExport; case EntryAction.info: - return 'Info'; + return context.l10n.entryActionInfo; case EntryAction.rename: - return 'Rename'; + return context.l10n.entryActionRename; case EntryAction.rotateCCW: - return 'Rotate counterclockwise'; + return context.l10n.entryActionRotateCCW; case EntryAction.rotateCW: - return 'Rotate clockwise'; + return context.l10n.entryActionRotateCW; case EntryAction.flip: - return 'Flip horizontally'; + return context.l10n.entryActionFlip; case EntryAction.print: - return 'Print'; + return context.l10n.entryActionPrint; case EntryAction.share: - return 'Share'; + return context.l10n.entryActionShare; case EntryAction.viewSource: - return 'View source'; + return context.l10n.entryActionViewSource; // external app actions case EntryAction.edit: - return 'Edit with…'; + return context.l10n.entryActionEdit; case EntryAction.open: - return 'Open with…'; + return context.l10n.entryActionOpen; case EntryAction.setAs: - return 'Set as…'; + return context.l10n.entryActionSetAs; case EntryAction.openMap: - return 'Show on map…'; + return context.l10n.entryActionOpenMap; case EntryAction.debug: return 'Debug'; } diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 303a75884..43c983f23 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -8,17 +8,29 @@ import 'package:google_api_availability/google_api_availability.dart'; import 'package:package_info/package_info.dart'; import 'package:version/version.dart'; -final AvesAvailability availability = AvesAvailability._private(); +abstract class AvesAvailability { + void onResume(); -class AvesAvailability { + Future get isConnected; + + Future get hasPlayServices; + + Future get canLocatePlaces; + + Future get isNewVersionAvailable; +} + +class LiveAvesAvailability implements AvesAvailability { bool _isConnected, _hasPlayServices, _isNewVersionAvailable; - AvesAvailability._private() { + LiveAvesAvailability() { Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); } + @override void onResume() => _isConnected = null; + @override Future get isConnected async { if (_isConnected != null) return SynchronousFuture(_isConnected); final result = await (Connectivity().checkConnectivity()); @@ -34,6 +46,7 @@ class AvesAvailability { } } + @override Future get hasPlayServices async { if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices); final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability(); @@ -43,8 +56,10 @@ class AvesAvailability { } // local geocoding with `geocoder` requires Play Services + @override Future get canLocatePlaces => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); + @override Future get isNewVersionAvailable async { if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); diff --git a/lib/model/covers.dart b/lib/model/covers.dart new file mode 100644 index 000000000..5a083438a --- /dev/null +++ b/lib/model/covers.dart @@ -0,0 +1,111 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/services/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +final Covers covers = Covers._private(); + +class Covers with ChangeNotifier { + Set _rows = {}; + + Covers._private(); + + Future init() async { + _rows = await metadataDb.loadCovers(); + } + + int get count => _rows.length; + + int coverContentId(CollectionFilter filter) => _rows.firstWhere((row) => row.filter == filter, orElse: () => null)?.contentId; + + Future set(CollectionFilter filter, int contentId) async { + // erase contextual properties from filters before saving them + if (filter is AlbumFilter) { + filter = AlbumFilter((filter as AlbumFilter).album, null); + } + + final row = CoverRow(filter: filter, contentId: contentId); + _rows.removeWhere((row) => row.filter == filter); + if (contentId == null) { + await metadataDb.removeCovers({row}); + } else { + _rows.add(row); + await metadataDb.addCovers({row}); + } + + notifyListeners(); + } + + Future moveEntry(int oldContentId, AvesEntry entry) async { + final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet(); + if (oldRows.isEmpty) return; + + for (final oldRow in oldRows) { + final filter = oldRow.filter; + _rows.remove(oldRow); + if (filter.test(entry)) { + final newRow = CoverRow(filter: filter, contentId: entry.contentId); + await metadataDb.updateCoverEntryId(oldRow.contentId, newRow); + _rows.add(newRow); + } else { + await metadataDb.removeCovers({oldRow}); + } + } + + notifyListeners(); + } + + Future removeEntries(Set entries) async { + final contentIds = entries.map((entry) => entry.contentId).toSet(); + final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); + + await metadataDb.removeCovers(removedRows); + _rows.removeAll(removedRows); + + notifyListeners(); + } + + Future clear() async { + await metadataDb.clearCovers(); + _rows.clear(); + + notifyListeners(); + } +} + +@immutable +class CoverRow { + final CollectionFilter filter; + final int contentId; + + const CoverRow({ + @required this.filter, + @required this.contentId, + }); + + factory CoverRow.fromMap(Map map) { + return CoverRow( + filter: CollectionFilter.fromJson(map['filter']), + contentId: map['contentId'], + ); + } + + Map toMap() => { + 'filter': filter.toJson(), + 'contentId': contentId, + }; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is CoverRow && other.filter == filter && other.contentId == contentId; + } + + @override + int get hashCode => hashValues(filter, contentId); + + @override + String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}'; +} diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 934e39cba..db0614254 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'package:aves/geo/countries.dart'; -import 'package:aves/model/availability.dart'; import 'package:aves/model/entry_cache.dart'; -import 'package:aves/model/favourite_repo.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/multipage.dart'; -import 'package:aves/services/image_file_service.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/service_policy.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/math_utils.dart'; @@ -18,7 +17,6 @@ import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:geocoder/geocoder.dart'; import 'package:latlong/latlong.dart'; import 'package:path/path.dart' as ppath; @@ -33,7 +31,7 @@ class AvesEntry { int height; int sourceRotationDegrees; final int sizeBytes; - String sourceTitle; + String _sourceTitle; // `dateModifiedSecs` can be missing in viewer mode int _dateModifiedSecs; @@ -45,10 +43,6 @@ class AvesEntry { final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); - // Local geocoding requires Google Play Services - // Google remote geocoding requires an API key and is not free - final Future> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates; - // TODO TLAD make it dynamic if it depends on OS/lib versions static const List undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd]; @@ -62,13 +56,14 @@ class AvesEntry { @required this.height, this.sourceRotationDegrees, this.sizeBytes, - this.sourceTitle, + String sourceTitle, int dateModifiedSecs, this.sourceDateTakenMillis, this.durationMillis, }) : assert(width != null), assert(height != null) { this.path = path; + this.sourceTitle = sourceTitle; this.dateModifiedSecs = dateModifiedSecs; } @@ -77,14 +72,14 @@ class AvesEntry { bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); AvesEntry copyWith({ - @required String uri, - @required String path, - @required int contentId, - @required int dateModifiedSecs, + String uri, + String path, + int contentId, + int dateModifiedSecs, }) { final copyContentId = contentId ?? this.contentId; final copied = AvesEntry( - uri: uri ?? uri, + uri: uri ?? this.uri, path: path ?? this.path, contentId: copyContentId, sourceMimeType: sourceMimeType, @@ -93,7 +88,7 @@ class AvesEntry { sourceRotationDegrees: sourceRotationDegrees, sizeBytes: sizeBytes, sourceTitle: sourceTitle, - dateModifiedSecs: dateModifiedSecs, + dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, ) @@ -241,9 +236,7 @@ class AvesEntry { bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff; - // as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved - // so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution - bool get useTiles => supportTiling && (width > 4096 || height > 4096 || is360); + bool get useTiles => supportTiling && (width > 4096 || height > 4096); bool get isRaw => MimeTypes.rawImages.contains(mimeType); @@ -347,6 +340,13 @@ class AvesEntry { set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; + String get sourceTitle => _sourceTitle; + + set sourceTitle(String sourceTitle) { + _sourceTitle = sourceTitle; + _bestTitle = null; + } + int get dateModifiedSecs => _dateModifiedSecs; set dateModifiedSecs(int dateModifiedSecs) { @@ -444,7 +444,7 @@ class AvesEntry { } catalogMetadata = CatalogMetadata(contentId: contentId); } else { - catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + catalogMetadata = await metadataService.getCatalogMetadata(this, background: background); } } @@ -479,12 +479,18 @@ class AvesEntry { ); } + String _geocoderLocale; + + String get geocoderLocale { + _geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance.window.locale).toString(); + return _geocoderLocale; + } + // full reverse geocoding, requiring Play Services and some connectivity Future locatePlace({@required bool background}) async { if (!hasGps || hasFineAddress) return; - final coordinates = latLng; try { - Future> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude)); + Future> call() => GeocodingService.getAddress(latLng, geocoderLocale); final addresses = await (background ? servicePolicy.call( call, @@ -507,22 +513,21 @@ class AvesEntry { ); } } catch (error, stack) { - debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stack'); + debugPrint('$runtimeType locate failed with path=$path coordinates=$latLng error=$error\n$stack'); } } Future findAddressLine() async { if (!hasGps) return null; - final coordinates = latLng; try { - final addresses = await _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude)); + final addresses = await GeocodingService.getAddress(latLng, geocoderLocale); if (addresses != null && addresses.isNotEmpty) { final address = addresses.first; return address.addressLine; } } catch (error, stack) { - debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stack'); + debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$latLng error=$error\n$stack'); } return null; } @@ -553,10 +558,7 @@ class AvesEntry { final contentId = newFields['contentId']; if (contentId is int) this.contentId = contentId; final sourceTitle = newFields['title']; - if (sourceTitle is String) { - this.sourceTitle = sourceTitle; - _bestTitle = null; - } + if (sourceTitle is String) this.sourceTitle = sourceTitle; final width = newFields['width']; if (width is int) this.width = width; @@ -576,18 +578,8 @@ class AvesEntry { metadataChangeNotifier.notifyListeners(); } - Future rename(String newName) async { - if (newName == filenameWithoutExtension) return true; - - final newFields = await ImageFileService.rename(this, '$newName$extension'); - if (newFields.isEmpty) return false; - - await _applyNewFields(newFields); - return true; - } - Future rotate({@required bool clockwise}) async { - final newFields = await ImageFileService.rotate(this, clockwise: clockwise); + final newFields = await imageFileService.rotate(this, clockwise: clockwise); if (newFields.isEmpty) return false; final oldDateModifiedSecs = dateModifiedSecs; @@ -599,7 +591,7 @@ class AvesEntry { } Future flip() async { - final newFields = await ImageFileService.flip(this); + final newFields = await imageFileService.flip(this); if (newFields.isEmpty) return false; final oldDateModifiedSecs = dateModifiedSecs; @@ -612,7 +604,7 @@ class AvesEntry { Future delete() { Completer completer = Completer(); - ImageFileService.delete([this]).listen( + imageFileService.delete([this]).listen( (event) => completer.complete(event.success), onError: completer.completeError, onDone: () { @@ -625,7 +617,7 @@ class AvesEntry { } // when the entry image itself changed (e.g. after rotation) - void _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { + Future _onImageChanged(int oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); imageChangeNotifier.notifyListeners(); @@ -634,23 +626,23 @@ class AvesEntry { // favourites - void toggleFavourite() { + Future toggleFavourite() async { if (isFavourite) { - removeFromFavourites(); + await removeFromFavourites(); } else { - addToFavourites(); + await addToFavourites(); } } - void addToFavourites() { + Future addToFavourites() async { if (!isFavourite) { - favourites.add([this]); + await favourites.add([this]); } } - void removeFromFavourites() { + Future removeFromFavourites() async { if (isFavourite) { - favourites.remove([this]); + await favourites.remove([this]); } } diff --git a/lib/model/favourite_repo.dart b/lib/model/favourite_repo.dart deleted file mode 100644 index e80eb6fd6..000000000 --- a/lib/model/favourite_repo.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:aves/model/entry.dart'; -import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; -import 'package:aves/utils/change_notifier.dart'; - -final FavouriteRepo favourites = FavouriteRepo._private(); - -class FavouriteRepo { - List _rows = []; - - final AChangeNotifier changeNotifier = AChangeNotifier(); - - FavouriteRepo._private(); - - Future init() async { - _rows = await metadataDb.loadFavourites(); - } - - int get count => _rows.length; - - bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId); - - FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path); - - Future add(Iterable entries) async { - final newRows = entries.map(_entryToRow); - - await metadataDb.addFavourites(newRows); - _rows.addAll(newRows); - - changeNotifier.notifyListeners(); - } - - Future remove(Iterable entries) async { - final removedRows = entries.map(_entryToRow); - - await metadataDb.removeFavourites(removedRows); - removedRows.forEach(_rows.remove); - - changeNotifier.notifyListeners(); - } - - Future move(int oldContentId, AvesEntry entry) async { - final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null); - final newRow = _entryToRow(entry); - - await metadataDb.updateFavouriteId(oldContentId, newRow); - _rows.remove(oldRow); - _rows.add(newRow); - - changeNotifier.notifyListeners(); - } - - Future clear() async { - await metadataDb.clearFavourites(); - _rows.clear(); - - changeNotifier.notifyListeners(); - } -} diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart new file mode 100644 index 000000000..1ae6d54b9 --- /dev/null +++ b/lib/model/favourites.dart @@ -0,0 +1,96 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/services/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +final Favourites favourites = Favourites._private(); + +class Favourites with ChangeNotifier { + Set _rows = {}; + + Favourites._private(); + + Future init() async { + _rows = await metadataDb.loadFavourites(); + } + + int get count => _rows.length; + + bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId); + + FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path); + + Future add(Iterable entries) async { + final newRows = entries.map(_entryToRow); + + await metadataDb.addFavourites(newRows); + _rows.addAll(newRows); + + notifyListeners(); + } + + Future remove(Iterable entries) async { + final contentIds = entries.map((entry) => entry.contentId).toSet(); + final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet(); + + await metadataDb.removeFavourites(removedRows); + removedRows.forEach(_rows.remove); + + notifyListeners(); + } + + Future moveEntry(int oldContentId, AvesEntry entry) async { + final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null); + if (oldRow == null) return; + + final newRow = _entryToRow(entry); + + await metadataDb.updateFavouriteId(oldContentId, newRow); + _rows.remove(oldRow); + _rows.add(newRow); + + notifyListeners(); + } + + Future clear() async { + await metadataDb.clearFavourites(); + _rows.clear(); + + notifyListeners(); + } +} + +@immutable +class FavouriteRow { + final int contentId; + final String path; + + const FavouriteRow({ + this.contentId, + this.path, + }); + + factory FavouriteRow.fromMap(Map map) { + return FavouriteRow( + contentId: map['contentId'], + path: map['path'] ?? '', + ); + } + + Map toMap() => { + 'contentId': contentId, + 'path': path, + }; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is FavouriteRow && other.contentId == contentId && other.path == path; + } + + @override + int get hashCode => hashValues(contentId, path); + + @override + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}'; +} diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index ec48d627b..a9b21613e 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -35,10 +35,10 @@ class AlbumFilter extends CollectionFilter { EntryFilter get test => (entry) => entry.directory == album; @override - String get label => uniqueName ?? album.split(separator).last; + String get universalLabel => uniqueName ?? album.split(separator).last; @override - String get tooltip => album; + String getTooltip(BuildContext context) => album; @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { @@ -74,7 +74,11 @@ class AlbumFilter extends CollectionFilter { } @override - String get typeKey => type; + String get category => type; + + // key `album-{path}` is expected by test driver + @override + String get key => '$type-$album'; @override bool operator ==(Object other) { diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 348bb4aeb..0c27c9c72 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -1,11 +1,15 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; class FavouriteFilter extends CollectionFilter { static const type = 'favourite'; + const FavouriteFilter(); + @override Map toMap() => { 'type': type, @@ -15,13 +19,22 @@ class FavouriteFilter extends CollectionFilter { EntryFilter get test => (entry) => entry.isFavourite; @override - String get label => 'Favourite'; + String get universalLabel => type; + + @override + String getLabel(BuildContext context) => context.l10n.filterFavouriteLabel; @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size); @override - String get typeKey => type; + Future color(BuildContext context) => SynchronousFuture(Colors.red); + + @override + String get category => type; + + @override + String get key => type; @override bool operator ==(Object other) { diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 6584497e6..fdfbf371d 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -14,7 +14,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; abstract class CollectionFilter implements Comparable { - static const List collectionFilterOrder = [ + static const List categoryOrder = [ QueryFilter.type, FavouriteFilter.type, MimeFilter.type, @@ -57,25 +57,28 @@ abstract class CollectionFilter implements Comparable { bool get isUnique => true; - String get label; + String get universalLabel; - String get tooltip => label; + String getLabel(BuildContext context) => universalLabel; + + String getTooltip(BuildContext context) => getLabel(context); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}); - Future color(BuildContext context) => SynchronousFuture(stringToColor(label)); + Future color(BuildContext context) => SynchronousFuture(stringToColor(getLabel(context))); - String get typeKey; - - int get displayPriority => collectionFilterOrder.indexOf(typeKey); + String get category; // to be used as widget key - String get key => '$typeKey-$label'; + String get key; + + int get displayPriority => categoryOrder.indexOf(category); @override int compareTo(CollectionFilter other) { final c = displayPriority.compareTo(other.displayPriority); - return c != 0 ? c : compareAsciiUpperCase(label, other.label); + // assume we compare context-independent labels + return c != 0 ? c : compareAsciiUpperCase(universalLabel, other.universalLabel); } } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 7b3e66c35..b92a0073b 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -1,11 +1,11 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class LocationFilter extends CollectionFilter { static const type = 'location'; - static const emptyLabel = 'unlocated'; static const locationSeparator = ';'; final LocationLevel level; @@ -48,7 +48,10 @@ class LocationFilter extends CollectionFilter { EntryFilter get test => _test; @override - String get label => _location.isEmpty ? emptyLabel : _location; + String get universalLabel => _location; + + @override + String getLabel(BuildContext context) => _location.isEmpty ? context.l10n.filterLocationEmptyLabel : _location; @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { @@ -66,7 +69,10 @@ class LocationFilter extends CollectionFilter { } @override - String get typeKey => type; + String get category => type; + + @override + String get key => '$type-$level-$_location'; @override bool operator ==(Object other) { diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 7b9fa56b7..5b1388589 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -1,6 +1,8 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/mime_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -17,14 +19,12 @@ class MimeFilter extends CollectionFilter { if (lowMime.endsWith('/*')) { lowMime = lowMime.substring(0, lowMime.length - 2); _test = (entry) => entry.mimeType.startsWith(lowMime); - if (lowMime == 'video') { - _label = 'Video'; - _icon = AIcons.video; - } else if (lowMime == 'image') { - _label = 'Image'; + _label = lowMime.toUpperCase(); + if (mime == MimeTypes.anyImage) { _icon = AIcons.image; + } else if (mime == MimeTypes.anyVideo) { + _icon = AIcons.video; } - _label ??= lowMime.split('/')[0].toUpperCase(); } else { _test = (entry) => entry.mimeType == lowMime; _label = MimeUtils.displayType(lowMime); @@ -47,13 +47,28 @@ class MimeFilter extends CollectionFilter { EntryFilter get test => _test; @override - String get label => _label; + String get universalLabel => _label; + + @override + String getLabel(BuildContext context) { + switch (mime) { + case MimeTypes.anyImage: + return context.l10n.filterMimeImageLabel; + case MimeTypes.anyVideo: + return context.l10n.filterMimeVideoLabel; + default: + return _label; + } + } @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); @override - String get typeKey => type; + String get category => type; + + @override + String get key => '$type-$mime'; @override bool operator ==(Object other) { diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index dcbf6064e..cab4778e7 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -50,7 +50,7 @@ class QueryFilter extends CollectionFilter { bool get isUnique => false; @override - String get label => '$query'; + String get universalLabel => query; @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size); @@ -59,7 +59,10 @@ class QueryFilter extends CollectionFilter { Future color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor); @override - String get typeKey => type; + String get category => type; + + @override + String get key => '$type-$query'; @override bool operator ==(Object other) { diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index bec9dbe74..3b39b82ac 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -1,11 +1,11 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class TagFilter extends CollectionFilter { static const type = 'tag'; - static const emptyLabel = 'untagged'; final String tag; EntryFilter _test; @@ -36,13 +36,19 @@ class TagFilter extends CollectionFilter { bool get isUnique => false; @override - String get label => tag.isEmpty ? emptyLabel : tag; + String get universalLabel => tag; + + @override + String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag; @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; @override - String get typeKey => type; + String get category => type; + + @override + String get key => '$type-$tag'; @override bool operator ==(Object other) { diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index 189e97164..f79dc6630 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -1,5 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -13,26 +14,26 @@ class TypeFilter extends CollectionFilter { final String itemType; EntryFilter _test; - String _label; IconData _icon; TypeFilter(this.itemType) { - if (itemType == animated) { - _test = (entry) => entry.isAnimated; - _label = 'Animated'; - _icon = AIcons.animated; - } else if (itemType == panorama) { - _test = (entry) => entry.isImage && entry.is360; - _label = 'Panorama'; - _icon = AIcons.threesixty; - } else if (itemType == sphericalVideo) { - _test = (entry) => entry.isVideo && entry.is360; - _label = '360° Video'; - _icon = AIcons.threesixty; - } else if (itemType == geotiff) { - _test = (entry) => entry.isGeotiff; - _label = 'GeoTIFF'; - _icon = AIcons.geo; + switch (itemType) { + case animated: + _test = (entry) => entry.isAnimated; + _icon = AIcons.animated; + break; + case panorama: + _test = (entry) => entry.isImage && entry.is360; + _icon = AIcons.threesixty; + break; + case sphericalVideo: + _test = (entry) => entry.isVideo && entry.is360; + _icon = AIcons.threesixty; + break; + case geotiff: + _test = (entry) => entry.isGeotiff; + _icon = AIcons.geo; + break; } } @@ -51,13 +52,32 @@ class TypeFilter extends CollectionFilter { EntryFilter get test => _test; @override - String get label => _label; + String get universalLabel => itemType; + + @override + String getLabel(BuildContext context) { + switch (itemType) { + case animated: + return context.l10n.filterTypeAnimatedLabel; + case panorama: + return context.l10n.filterTypePanoramaLabel; + case sphericalVideo: + return context.l10n.filterTypeSphericalVideoLabel; + case geotiff: + return context.l10n.filterTypeGeotiffLabel; + default: + return itemType; + } + } @override Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); @override - String get typeKey => type; + String get category => type; + + @override + String get key => '$type-$itemType'; @override bool operator ==(Object other) { diff --git a/lib/model/metadata.dart b/lib/model/metadata.dart index ea7c7a4bd..9a045d36d 100644 --- a/lib/model/metadata.dart +++ b/lib/model/metadata.dart @@ -1,6 +1,6 @@ +import 'package:aves/services/geocoding_service.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:geocoder/model.dart'; import 'package:intl/intl.dart'; class DateMetadata { @@ -204,38 +204,3 @@ class AddressDetails { @override String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } - -@immutable -class FavouriteRow { - final int contentId; - final String path; - - const FavouriteRow({ - this.contentId, - this.path, - }); - - factory FavouriteRow.fromMap(Map map) { - return FavouriteRow( - contentId: map['contentId'], - path: map['path'] ?? '', - ); - } - - Map toMap() => { - 'contentId': contentId, - 'path': path, - }; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is FavouriteRow && other.contentId == contentId && other.path == path; - } - - @override - int get hashCode => hashValues(contentId, path); - - @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}'; -} diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index d7543ef19..561978da0 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -1,15 +1,85 @@ import 'dart:io'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; -final MetadataDb metadataDb = MetadataDb._private(); +abstract class MetadataDb { + Future init(); -class MetadataDb { + Future dbFileSize(); + + Future reset(); + + Future removeIds(Set contentIds, {@required bool metadataOnly}); + + // entries + + Future clearEntries(); + + Future> loadEntries(); + + Future saveEntries(Iterable entries); + + Future updateEntryId(int oldId, AvesEntry entry); + + // date taken + + Future clearDates(); + + Future> loadDates(); + + // catalog metadata + + Future clearMetadataEntries(); + + Future> loadMetadataEntries(); + + Future saveMetadata(Iterable metadataEntries); + + Future updateMetadataId(int oldId, CatalogMetadata metadata); + + // address + + Future clearAddresses(); + + Future> loadAddresses(); + + Future saveAddresses(Iterable addresses); + + Future updateAddressId(int oldId, AddressDetails address); + + // favourites + + Future clearFavourites(); + + Future> loadFavourites(); + + Future addFavourites(Iterable rows); + + Future updateFavouriteId(int oldId, FavouriteRow row); + + Future removeFavourites(Iterable rows); + + // covers + + Future clearCovers(); + + Future> loadCovers(); + + Future addCovers(Iterable rows); + + Future updateCoverEntryId(int oldId, CoverRow row); + + Future removeCovers(Iterable rows); +} + +class SqfliteMetadataDb implements MetadataDb { Future _database; Future get path async => join(await getDatabasesPath(), 'metadata.db'); @@ -19,9 +89,9 @@ class MetadataDb { static const metadataTable = 'metadata'; static const addressTable = 'address'; static const favouriteTable = 'favourites'; + static const coverTable = 'covers'; - MetadataDb._private(); - + @override Future init() async { debugPrint('$runtimeType init'); _database = openDatabase( @@ -68,17 +138,23 @@ class MetadataDb { 'contentId INTEGER PRIMARY KEY' ', path TEXT' ')'); + await db.execute('CREATE TABLE $coverTable(' + 'filter TEXT PRIMARY KEY' + ', contentId INTEGER' + ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 3, + version: 4, ); } + @override Future dbFileSize() async { final file = File((await path)); return await file.exists() ? file.length() : 0; } + @override Future reset() async { debugPrint('$runtimeType reset'); await (await _database).close(); @@ -86,7 +162,8 @@ class MetadataDb { await init(); } - void removeIds(Set contentIds, {@required bool updateFavourites}) async { + @override + Future removeIds(Set contentIds, {@required bool metadataOnly}) async { if (contentIds == null || contentIds.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -100,8 +177,9 @@ class MetadataDb { batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(metadataTable, where: where, whereArgs: whereArgs); batch.delete(addressTable, where: where, whereArgs: whereArgs); - if (updateFavourites) { + if (!metadataOnly) { batch.delete(favouriteTable, where: where, whereArgs: whereArgs); + batch.delete(coverTable, where: where, whereArgs: whereArgs); } }); await batch.commit(noResult: true); @@ -110,12 +188,14 @@ class MetadataDb { // entries + @override Future clearEntries() async { final db = await _database; final count = await db.delete(entryTable, where: '1'); debugPrint('$runtimeType clearEntries deleted $count entries'); } + @override Future> loadEntries() async { final stopwatch = Stopwatch()..start(); final db = await _database; @@ -125,6 +205,7 @@ class MetadataDb { return entries; } + @override Future saveEntries(Iterable entries) async { if (entries == null || entries.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -135,6 +216,7 @@ class MetadataDb { debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); } + @override Future updateEntryId(int oldId, AvesEntry entry) async { final db = await _database; final batch = db.batch(); @@ -154,12 +236,14 @@ class MetadataDb { // date taken + @override Future clearDates() async { final db = await _database; final count = await db.delete(dateTakenTable, where: '1'); debugPrint('$runtimeType clearDates deleted $count entries'); } + @override Future> loadDates() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -171,12 +255,14 @@ class MetadataDb { // catalog metadata + @override Future clearMetadataEntries() async { final db = await _database; final count = await db.delete(metadataTable, where: '1'); debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); } + @override Future> loadMetadataEntries() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -186,6 +272,7 @@ class MetadataDb { return metadataEntries; } + @override Future saveMetadata(Iterable metadataEntries) async { if (metadataEntries == null || metadataEntries.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -200,6 +287,7 @@ class MetadataDb { } } + @override Future updateMetadataId(int oldId, CatalogMetadata metadata) async { final db = await _database; final batch = db.batch(); @@ -227,12 +315,14 @@ class MetadataDb { // address + @override Future clearAddresses() async { final db = await _database; final count = await db.delete(addressTable, where: '1'); debugPrint('$runtimeType clearAddresses deleted $count entries'); } + @override Future> loadAddresses() async { // final stopwatch = Stopwatch()..start(); final db = await _database; @@ -242,6 +332,7 @@ class MetadataDb { return addresses; } + @override Future saveAddresses(Iterable addresses) async { if (addresses == null || addresses.isEmpty) return; final stopwatch = Stopwatch()..start(); @@ -252,6 +343,7 @@ class MetadataDb { debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); } + @override Future updateAddressId(int oldId, AddressDetails address) async { final db = await _database; final batch = db.batch(); @@ -271,31 +363,31 @@ class MetadataDb { // favourites + @override Future clearFavourites() async { final db = await _database; final count = await db.delete(favouriteTable, where: '1'); debugPrint('$runtimeType clearFavourites deleted $count entries'); } - Future> loadFavourites() async { -// final stopwatch = Stopwatch()..start(); + @override + Future> loadFavourites() async { final db = await _database; final maps = await db.query(favouriteTable); - final favouriteRows = maps.map((map) => FavouriteRow.fromMap(map)).toList(); -// debugPrint('$runtimeType loadFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); - return favouriteRows; + final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet(); + return rows; } - Future addFavourites(Iterable favouriteRows) async { - if (favouriteRows == null || favouriteRows.isEmpty) return; -// final stopwatch = Stopwatch()..start(); + @override + Future addFavourites(Iterable rows) async { + if (rows == null || rows.isEmpty) return; final db = await _database; final batch = db.batch(); - favouriteRows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row)); + rows.where((row) => row != null).forEach((row) => _batchInsertFavourite(batch, row)); await batch.commit(noResult: true); -// debugPrint('$runtimeType addFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); } + @override Future updateFavouriteId(int oldId, FavouriteRow row) async { final db = await _database; final batch = db.batch(); @@ -313,9 +405,10 @@ class MetadataDb { ); } - Future removeFavourites(Iterable favouriteRows) async { - if (favouriteRows == null || favouriteRows.isEmpty) return; - final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); + @override + Future removeFavourites(Iterable rows) async { + if (rows == null || rows.isEmpty) return; + final ids = rows.where((row) => row != null).map((row) => row.contentId); if (ids.isEmpty) return; final db = await _database; @@ -324,4 +417,61 @@ class MetadataDb { ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); await batch.commit(noResult: true); } + + // covers + + @override + Future clearCovers() async { + final db = await _database; + final count = await db.delete(coverTable, where: '1'); + debugPrint('$runtimeType clearCovers deleted $count entries'); + } + + @override + Future> loadCovers() async { + final db = await _database; + final maps = await db.query(coverTable); + final rows = maps.map((map) => CoverRow.fromMap(map)).toSet(); + return rows; + } + + @override + Future addCovers(Iterable rows) async { + if (rows == null || rows.isEmpty) return; + final db = await _database; + final batch = db.batch(); + rows.where((row) => row != null).forEach((row) => _batchInsertCover(batch, row)); + await batch.commit(noResult: true); + } + + @override + Future updateCoverEntryId(int oldId, CoverRow row) async { + final db = await _database; + final batch = db.batch(); + batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]); + _batchInsertCover(batch, row); + await batch.commit(noResult: true); + } + + void _batchInsertCover(Batch batch, CoverRow row) { + if (row == null) return; + batch.insert( + coverTable, + row.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + @override + Future removeCovers(Iterable rows) async { + if (rows == null || rows.isEmpty) return; + final filters = rows.where((row) => row != null).map((row) => row.filter); + if (filters.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(); + filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()])); + await batch.commit(noResult: true); + } } diff --git a/lib/model/metadata_db_upgrade.dart b/lib/model/metadata_db_upgrade.dart index b343eaf12..bb74bb11e 100644 --- a/lib/model/metadata_db_upgrade.dart +++ b/lib/model/metadata_db_upgrade.dart @@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; class MetadataDbUpgrader { - static const entryTable = MetadataDb.entryTable; - static const metadataTable = MetadataDb.metadataTable; + static const entryTable = SqfliteMetadataDb.entryTable; + static const metadataTable = SqfliteMetadataDb.metadataTable; + static const coverTable = SqfliteMetadataDb.coverTable; // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // on SQLite <3.25.0, bundled on older Android devices @@ -17,6 +18,9 @@ class MetadataDbUpgrader { case 2: await _upgradeFrom2(db); break; + case 3: + await _upgradeFrom3(db); + break; } oldVersion++; } @@ -97,4 +101,12 @@ class MetadataDbUpgrader { await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); }); } + + static Future _upgradeFrom3(Database db) async { + debugPrint('upgrading DB from v3'); + await db.execute('CREATE TABLE $coverTable(' + 'filter TEXT PRIMARY KEY' + ', contentId INTEGER' + ')'); + } } diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index 257b09e85..2006b24ee 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -71,7 +71,7 @@ class SinglePageInfo implements Comparable { final index = map['page'] as int; return SinglePageInfo( index: index, - pageId: map['trackId'] as int ?? index, + pageId: index, mimeType: map['mimeType'] as String, isDefault: map['isDefault'] as bool ?? false, width: map['width'] as int ?? 0, diff --git a/lib/model/settings/coordinate_format.dart b/lib/model/settings/coordinate_format.dart index 7e0518537..159991ab8 100644 --- a/lib/model/settings/coordinate_format.dart +++ b/lib/model/settings/coordinate_format.dart @@ -1,15 +1,17 @@ import 'package:aves/geo/format.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; import 'package:latlong/latlong.dart'; -enum CoordinateFormat { dms, decimal } +import 'enums.dart'; extension ExtraCoordinateFormat on CoordinateFormat { - String get name { + String getName(BuildContext context) { switch (this) { case CoordinateFormat.dms: - return 'DMS'; + return context.l10n.coordinateFormatDms; case CoordinateFormat.decimal: - return 'Decimal degrees'; + return context.l10n.coordinateFormatDecimal; default: return toString(); } diff --git a/lib/model/settings/entry_background.dart b/lib/model/settings/entry_background.dart index ee0ffe4c1..14f83f071 100644 --- a/lib/model/settings/entry_background.dart +++ b/lib/model/settings/entry_background.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -enum EntryBackground { black, white, transparent, checkered } +import 'enums.dart'; extension ExtraEntryBackground on EntryBackground { bool get isColor { diff --git a/lib/model/settings/enums.dart b/lib/model/settings/enums.dart new file mode 100644 index 000000000..c6d32dde6 --- /dev/null +++ b/lib/model/settings/enums.dart @@ -0,0 +1,10 @@ +enum CoordinateFormat { dms, decimal } + +enum EntryBackground { black, white, transparent, checkered } + +enum HomePageSetting { collection, albums } + +// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ +enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } + +enum KeepScreenOn { never, viewerOnly, always } diff --git a/lib/model/settings/home_page.dart b/lib/model/settings/home_page.dart index bc5c29b75..8c4aa09a6 100644 --- a/lib/model/settings/home_page.dart +++ b/lib/model/settings/home_page.dart @@ -1,15 +1,17 @@ import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:flutter/widgets.dart'; -enum HomePageSetting { collection, albums } +import 'enums.dart'; extension ExtraHomePageSetting on HomePageSetting { - String get name { + String getName(BuildContext context) { switch (this) { case HomePageSetting.collection: - return 'Collection'; + return context.l10n.collectionPageTitle; case HomePageSetting.albums: - return 'Albums'; + return context.l10n.albumPageTitle; default: return toString(); } diff --git a/lib/model/settings/map_style.dart b/lib/model/settings/map_style.dart index 25559107a..954edb2f2 100644 --- a/lib/model/settings/map_style.dart +++ b/lib/model/settings/map_style.dart @@ -1,21 +1,23 @@ -// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ -enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +import 'enums.dart'; extension ExtraEntryMapStyle on EntryMapStyle { - String get name { + String getName(BuildContext context) { switch (this) { case EntryMapStyle.googleNormal: - return 'Google Maps'; + return context.l10n.mapStyleGoogleNormal; case EntryMapStyle.googleHybrid: - return 'Google Maps (Hybrid)'; + return context.l10n.mapStyleGoogleHybrid; case EntryMapStyle.googleTerrain: - return 'Google Maps (Terrain)'; + return context.l10n.mapStyleGoogleTerrain; case EntryMapStyle.osmHot: - return 'Humanitarian OSM'; + return context.l10n.mapStyleOsmHot; case EntryMapStyle.stamenToner: - return 'Stamen Toner'; + return context.l10n.mapStyleStamenToner; case EntryMapStyle.stamenWatercolor: - return 'Stamen Watercolor'; + return context.l10n.mapStyleStamenWatercolor; default: return toString(); } diff --git a/lib/model/settings/screen_on.dart b/lib/model/settings/screen_on.dart index a11d3d7e4..ff7e851f0 100644 --- a/lib/model/settings/screen_on.dart +++ b/lib/model/settings/screen_on.dart @@ -1,16 +1,18 @@ import 'package:aves/services/window_service.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; -enum KeepScreenOn { never, viewerOnly, always } +import 'enums.dart'; extension ExtraKeepScreenOn on KeepScreenOn { - String get name { + String getName(BuildContext context) { switch (this) { case KeepScreenOn.never: - return 'Never'; + return context.l10n.keepScreenOnNever; case KeepScreenOn.viewerOnly: - return 'Viewer page only'; + return context.l10n.keepScreenOnViewerOnly; case KeepScreenOn.always: - return 'Always'; + return context.l10n.keepScreenOnAlways; default: return toString(); } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index d436d7ca9..cf52147ad 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,18 +1,14 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/settings/coordinate_format.dart'; -import 'package:aves/model/settings/entry_background.dart'; -import 'package:aves/model/settings/home_page.dart'; -import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:pedantic/pedantic.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../source/enums.dart'; +import 'enums.dart'; final Settings settings = Settings._private(); @@ -24,6 +20,7 @@ class Settings extends ChangeNotifier { // app static const hasAcceptedTermsKey = 'has_accepted_terms'; static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; + static const localeKey = 'locale'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const keepScreenOnKey = 'keep_screen_on'; static const homePageKey = 'home_page'; @@ -99,6 +96,34 @@ class Settings extends ChangeNotifier { unawaited(initFirebase()); } + static const localeSeparator = '-'; + + Locale get locale { + // exceptionally allow getting locale before settings are initialized + final tag = _prefs?.getString(localeKey); + if (tag != null) { + final codes = tag.split(localeSeparator); + return Locale.fromSubtags( + languageCode: codes[0], + scriptCode: codes[1] == '' ? null : codes[1], + countryCode: codes[2] == '' ? null : codes[2], + ); + } + return null; + } + + set locale(Locale newValue) { + String tag; + if (newValue != null) { + tag = [ + newValue.languageCode ?? '', + newValue.scriptCode ?? '', + newValue.countryCode ?? '', + ].join(localeSeparator); + } + setAndNotify(localeKey, tag); + } + bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, true); set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue); @@ -120,7 +145,7 @@ class Settings extends ChangeNotifier { double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0; - // do not notify, as tile extents are only used internally by `TileExtentManager` + // do not notify, as tile extents are only used internally by `TileExtentController` // and should not trigger rebuilding by change notification void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false); @@ -182,7 +207,7 @@ class Settings extends ChangeNotifier { set showOverlayInfo(bool newValue) => setAndNotify(showOverlayInfoKey, newValue); - bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, true); + bool get showOverlayShootingDetails => getBoolOrDefault(showOverlayShootingDetailsKey, false); set showOverlayShootingDetails(bool newValue) => setAndNotify(showOverlayShootingDetailsKey, newValue); diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 8f78d6c18..699aaa54c 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -4,6 +4,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; import 'package:path/path.dart'; mixin AlbumMixin on SourceBase { @@ -12,8 +13,8 @@ mixin AlbumMixin on SourceBase { List get rawAlbums => List.unmodifiable(_directories); int compareAlbumsByName(String a, String b) { - final ua = getUniqueAlbumName(a); - final ub = getUniqueAlbumName(b); + final ua = getUniqueAlbumName(null, a); + final ub = getUniqueAlbumName(null, b); final c = compareAsciiUpperCase(ua, ub); if (c != 0) return c; final va = androidFileUtils.getStorageVolume(a)?.path ?? ''; @@ -23,7 +24,7 @@ mixin AlbumMixin on SourceBase { void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); - String getUniqueAlbumName(String dirPath) { + String getUniqueAlbumName(BuildContext context, String dirPath) { String unique(String dirPath, [bool Function(String) test]) { final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath); final parts = dirPath.split(separator); @@ -51,7 +52,7 @@ mixin AlbumMixin on SourceBase { if (volume.isPrimary) { return uniqueNameInVolume; } else { - return '$uniqueNameInVolume (${volume.description})'; + return '$uniqueNameInVolume (${volume.getDescription(context)})'; } } } @@ -99,7 +100,7 @@ mixin AlbumMixin on SourceBase { invalidateAlbumFilterSummary(directories: emptyAlbums); final pinnedFilters = settings.pinnedFilters; - emptyAlbums.forEach((album) => pinnedFilters.remove(AlbumFilter(album, getUniqueAlbumName(album)))); + emptyAlbums.forEach((album) => pinnedFilters.removeWhere((filter) => filter is AlbumFilter && filter.album == album)); settings.pinnedFilters = pinnedFilters; } } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 4c15ee0e8..9531ed566 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -92,7 +92,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin { void addFilter(CollectionFilter filter) { if (filter == null || filters.contains(filter)) return; if (filter.isUnique) { - filters.removeWhere((old) => old.typeKey == filter.typeKey); + filters.removeWhere((old) => old.category == filter.category); } filters.add(filter); onFilterChanged(); diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index ead56b9a5..9339e3c78 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -1,18 +1,20 @@ import 'dart:async'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourite_repo.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/services/image_op_events.dart'; +import 'package:aves/services/services.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; @@ -98,10 +100,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM eventBus.fire(EntryAddedEvent(entries)); } - void removeEntries(Set uris) { + Future removeEntries(Set uris) async { if (uris.isEmpty) return; final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); - entries.forEach((entry) => entry.removeFromFavourites()); + await favourites.remove(entries); + await covers.removeEntries(entries); _rawEntries.removeAll(entries); _invalidate(entries); @@ -120,30 +123,61 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM updateTags(); } - Future _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { + Future _moveEntry(AvesEntry entry, Map newFields) async { final oldContentId = entry.contentId; final newContentId = newFields['contentId'] as int; - final newDateModifiedSecs = newFields['dateModifiedSecs'] as int; + + entry.contentId = newContentId; // `dateModifiedSecs` changes when moving entries to another directory, // but it does not change when renaming the containing directory - if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs; - entry.path = newFields['path'] as String; - entry.uri = newFields['uri'] as String; - entry.contentId = newContentId; + if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int; + if (newFields.containsKey('path')) entry.path = newFields['path'] as String; + if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String; + if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String; + entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId); entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId); await metadataDb.updateEntryId(oldContentId, entry); await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); await metadataDb.updateAddressId(oldContentId, entry.addressDetails); - if (isFavourite) { - await favourites.move(oldContentId, entry); + await favourites.moveEntry(oldContentId, entry); + await covers.moveEntry(oldContentId, entry); + } + + Future renameEntry(AvesEntry entry, String newName) async { + if (newName == entry.filenameWithoutExtension) return true; + final newFields = await imageFileService.rename(entry, '$newName${entry.extension}'); + if (newFields.isEmpty) return false; + + await _moveEntry(entry, newFields); + entry.metadataChangeNotifier.notifyListeners(); + return true; + } + + Future renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set movedOps) async { + final oldFilter = AlbumFilter(sourceAlbum, null); + final pinned = settings.pinnedFilters.contains(oldFilter); + final oldCoverContentId = covers.coverContentId(oldFilter); + final coverEntry = oldCoverContentId != null ? todoEntries.firstWhere((entry) => entry.contentId == oldCoverContentId, orElse: () => null) : null; + await updateAfterMove( + todoEntries: todoEntries, + copy: false, + destinationAlbum: destinationAlbum, + movedOps: movedOps, + ); + // restore pin and cover, as the obsolete album got removed and its associated state cleaned + final newFilter = AlbumFilter(destinationAlbum, null); + if (pinned) { + settings.pinnedFilters = settings.pinnedFilters..add(newFilter); + } + if (coverEntry != null) { + await covers.set(newFilter, coverEntry.contentId); } } - void updateAfterMove({ + Future updateAfterMove({ @required Set todoEntries, - @required Set favouriteEntries, @required bool copy, @required String destinationAlbum, @required Set movedOps, @@ -177,10 +211,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM if (entry != null) { fromAlbums.add(entry.directory); movedEntries.add(entry); - // do not rely on current favourite repo state to assess whether the moved entry is a favourite - // as source monitoring may already have removed the entry from the favourite repo - final isFavourite = favouriteEntries.contains(entry); - await _moveEntry(entry, newFields, isFavourite); + await _moveEntry(entry, newFields); } } }); @@ -231,6 +262,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return null; } + AvesEntry coverEntry(CollectionFilter filter) { + final contentId = covers.coverContentId(filter); + if (contentId != null) { + final entry = visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + if (entry != null) return entry; + } + return recentEntry(filter); + } + void changeFilterVisibility(CollectionFilter filter, bool visible) { final hiddenFilters = settings.hiddenFilters; if (visible) { @@ -256,8 +296,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } } -enum SourceState { loading, cataloguing, locating, ready } - class EntryAddedEvent { final Set entries; diff --git a/lib/model/source/enums.dart b/lib/model/source/enums.dart index a1ba59bb3..3721deeeb 100644 --- a/lib/model/source/enums.dart +++ b/lib/model/source/enums.dart @@ -1,5 +1,7 @@ enum Activity { browse, select } +enum SourceState { loading, cataloguing, locating, ready } + enum ChipSortFactor { date, name, count } enum AlbumChipGroupFactor { none, importance, volume } diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index 124c9a8d1..cfb178d04 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -1,12 +1,12 @@ import 'dart:math'; import 'package:aves/geo/countries.dart'; -import 'package:aves/model/availability.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/services/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:tuple/tuple.dart'; @@ -81,14 +81,14 @@ mixin LocationMixin on SourceBase { // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village) // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision final latLngFactor = pow(10, 2); - Tuple2 approximateLatLng(AvesEntry entry) { + Tuple2 approximateLatLng(AvesEntry entry) { final lat = entry.catalogMetadata?.latitude; final lng = entry.catalogMetadata?.longitude; if (lat == null || lng == null) return null; - return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); + return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round()); } - final knownLocations = {}; + final knownLocations = , AddressDetails>{}; byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails)); stateNotifier.value = SourceState.locating; @@ -138,7 +138,7 @@ mixin LocationMixin on SourceBase { } // the same country code could be found with different country names - // e.g. if the locale changed between geolocating calls + // e.g. if the locale changed between geocoding calls // so we merge countries by code, keeping only one name for each code final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty)); final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 83b310368..b4e65fd2d 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'dart:math'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourite_repo.dart'; -import 'package:aves/model/metadata_db.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/image_file_service.dart'; -import 'package:aves/services/media_store_service.dart'; -import 'package:aves/services/time_service.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; @@ -26,7 +25,8 @@ class MediaStoreSource extends CollectionSource { stateNotifier.value = SourceState.loading; await metadataDb.init(); await favourites.init(); - final currentTimeZone = await TimeService.getDefaultTimeZone(); + await covers.init(); + final currentTimeZone = await timeService.getDefaultTimeZone(); final catalogTimeZone = settings.catalogTimeZone; if (currentTimeZone != catalogTimeZone) { // clear catalog metadata to get correct date/times when moving to a different time zone @@ -50,7 +50,7 @@ class MediaStoreSource extends CollectionSource { final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries 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(); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); // show known entries @@ -60,11 +60,11 @@ class MediaStoreSource extends CollectionSource { debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); // clean up obsolete entries - metadataDb.removeIds(obsoleteContentIds, updateFavourites: true); + await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false); // verify paths because some apps move files without updating their `last modified date` final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId, entry.path))); - final movedContentIds = (await MediaStoreService.checkObsoletePaths(knownPathById)).toSet(); + final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet(); movedContentIds.forEach((contentId) { // make obsolete by resetting its modified date knownDateById[contentId] = 0; @@ -81,7 +81,7 @@ class MediaStoreSource extends CollectionSource { pendingNewEntries.clear(); } - MediaStoreService.getEntries(knownDateById).listen( + mediaStoreService.getEntries(knownDateById).listen( (entry) { pendingNewEntries.add(entry); if (pendingNewEntries.length >= refreshCount) { @@ -114,6 +114,7 @@ class MediaStoreSource extends CollectionSource { } void _reportCollectionDimensions() { + if (!settings.isCrashlyticsEnabled) return; final analytics = FirebaseAnalytics(); analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()); analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()); @@ -141,9 +142,9 @@ class MediaStoreSource extends CollectionSource { }).where((kv) => kv != null)); // clean up obsolete entries - final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); + final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet(); - removeEntries(obsoleteUris); + await removeEntries(obsoleteUris); obsoleteContentIds.forEach(uriByContentId.remove); // fetch new entries @@ -153,7 +154,7 @@ class MediaStoreSource extends CollectionSource { for (final kv in uriByContentId.entries) { final contentId = kv.key; final uri = kv.value; - final sourceEntry = await ImageFileService.getEntry(uri, null); + final sourceEntry = await imageFileService.getEntry(uri, null); if (sourceEntry != null) { final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); // compare paths because some apps move files without updating their `last modified date` @@ -188,7 +189,7 @@ class MediaStoreSource extends CollectionSource { @override Future refreshMetadata(Set entries) { final contentIds = entries.map((entry) => entry.contentId).toSet(); - metadataDb.removeIds(contentIds, updateFavourites: false); + metadataDb.removeIds(contentIds, metadataOnly: true); return refresh(); } } diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 897ba577e..dec9b0df3 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -1,8 +1,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/services/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index 9a379334d..38ae05445 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -41,7 +41,6 @@ class AndroidAppService { static Future edit(String uri, String mimeType) async { try { return await platform.invokeMethod('edit', { - 'title': 'Edit with:', 'uri': uri, 'mimeType': mimeType, }); @@ -54,7 +53,6 @@ class AndroidAppService { static Future open(String uri, String mimeType) async { try { return await platform.invokeMethod('open', { - 'title': 'Open with:', 'uri': uri, 'mimeType': mimeType, }); @@ -78,7 +76,6 @@ class AndroidAppService { static Future setAs(String uri, String mimeType) async { try { return await platform.invokeMethod('setAs', { - 'title': 'Set as:', 'uri': uri, 'mimeType': mimeType, }); @@ -94,7 +91,6 @@ class AndroidAppService { final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); try { return await platform.invokeMethod('share', { - 'title': 'Share via:', 'urisByMimeType': urisByMimeType, }); } on PlatformException catch (e) { @@ -106,7 +102,6 @@ class AndroidAppService { static Future shareSingle(String uri, String mimeType) async { try { return await platform.invokeMethod('share', { - 'title': 'Share via:', 'urisByMimeType': { mimeType: [uri] }, diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 02a3f76f6..9c5a1cb46 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -2,7 +2,7 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -30,7 +30,7 @@ class AppShortcutService { Uint8List iconBytes; if (entry != null) { final size = entry.isVideo ? 0.0 : 256.0; - iconBytes = await ImageFileService.getThumbnail( + iconBytes = await imageFileService.getThumbnail( uri: entry.uri, mimeType: entry.mimeType, pageId: entry.pageId, diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart new file mode 100644 index 000000000..6ad6f23cf --- /dev/null +++ b/lib/services/geocoding_service.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:latlong/latlong.dart'; + +class GeocodingService { + static const platform = MethodChannel('deckers.thibault/aves/geocoding'); + + // geocoding requires Google Play Services + static Future> getAddress(LatLng coordinates, String locale) async { + try { + final result = await platform.invokeMethod('getAddress', { + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'locale': locale, + // we only really need one address, but sometimes the native geocoder + // returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+ + 'maxResults': 2, + }); + return (result as List).cast().map((map) => Address.fromMap(map)).toList(); + } on PlatformException catch (e) { + debugPrint('getAddress failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return []; + } +} + +@immutable +class Address { + final String addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare; + + const Address({ + this.addressLine, + this.adminArea, + this.countryCode, + this.countryName, + this.featureName, + this.locality, + this.postalCode, + this.subAdminArea, + this.subLocality, + this.subThoroughfare, + this.thoroughfare, + }); + + factory Address.fromMap(Map map) => Address( + addressLine: map['addressLine'], + adminArea: map['adminArea'], + countryCode: map['countryCode'], + countryName: map['countryName'], + featureName: map['featureName'], + locality: map['locality'], + postalCode: map['postalCode'], + subAdminArea: map['subAdminArea'], + subLocality: map['subLocality'], + subThoroughfare: map['subThoroughfare'], + thoroughfare: map['thoroughfare'], + ); +} diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 45208ca56..e5d60f95e 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -11,7 +11,82 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:streams_channel/streams_channel.dart'; -class ImageFileService { +abstract class ImageFileService { + Future getEntry(String uri, String mimeType); + + Future getSvg( + String uri, + String mimeType, { + int expectedContentLength, + BytesReceivedCallback onBytesReceived, + }); + + Future getImage( + String uri, + String mimeType, + int rotationDegrees, + bool isFlipped, { + int pageId, + int expectedContentLength, + BytesReceivedCallback onBytesReceived, + }); + + // `rect`: region to decode, with coordinates in reference to `imageSize` + Future getRegion( + String uri, + String mimeType, + int rotationDegrees, + bool isFlipped, + int sampleSize, + Rectangle regionRect, + Size imageSize, { + int pageId, + Object taskKey, + int priority, + }); + + Future getThumbnail({ + @required String uri, + @required String mimeType, + @required int rotationDegrees, + @required int pageId, + @required bool isFlipped, + @required int dateModifiedSecs, + @required double extent, + Object taskKey, + int priority, + }); + + Future clearSizedThumbnailDiskCache(); + + bool cancelRegion(Object taskKey); + + bool cancelThumbnail(Object taskKey); + + Future resumeLoading(Object taskKey); + + Stream delete(Iterable entries); + + Stream move( + Iterable entries, { + @required bool copy, + @required String destinationAlbum, + }); + + Stream export( + Iterable entries, { + String mimeType = MimeTypes.jpeg, + @required String destinationAlbum, + }); + + Future rename(AvesEntry entry, String newName); + + Future rotate(AvesEntry entry, {@required bool clockwise}); + + Future flip(AvesEntry entry); +} + +class PlatformImageFileService implements ImageFileService { static const platform = MethodChannel('deckers.thibault/aves/image'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); @@ -31,7 +106,8 @@ class ImageFileService { }; } - static Future getEntry(String uri, String mimeType) async { + @override + Future getEntry(String uri, String mimeType) async { try { final result = await platform.invokeMethod('getEntry', { 'uri': uri, @@ -44,7 +120,8 @@ class ImageFileService { return null; } - static Future getSvg( + @override + Future getSvg( String uri, String mimeType, { int expectedContentLength, @@ -59,7 +136,8 @@ class ImageFileService { onBytesReceived: onBytesReceived, ); - static Future getImage( + @override + Future getImage( String uri, String mimeType, int rotationDegrees, @@ -106,8 +184,8 @@ class ImageFileService { return Future.sync(() => null); } - // `rect`: region to decode, with coordinates in reference to `imageSize` - static Future getRegion( + @override + Future getRegion( String uri, String mimeType, int rotationDegrees, @@ -145,7 +223,8 @@ class ImageFileService { ); } - static Future getThumbnail({ + @override + Future getThumbnail({ @required String uri, @required String mimeType, @required int rotationDegrees, @@ -184,7 +263,8 @@ class ImageFileService { ); } - static Future clearSizedThumbnailDiskCache() async { + @override + Future clearSizedThumbnailDiskCache() async { try { return platform.invokeMethod('clearSizedThumbnailDiskCache'); } on PlatformException catch (e) { @@ -192,13 +272,17 @@ class ImageFileService { } } - static bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); + @override + bool cancelRegion(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getRegion]); - static bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); + @override + bool cancelThumbnail(Object taskKey) => servicePolicy.pause(taskKey, [ServiceCallPriority.getFastThumbnail, ServiceCallPriority.getSizedThumbnail]); - static Future resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); + @override + Future resumeLoading(Object taskKey) => servicePolicy.resume(taskKey); - static Stream delete(Iterable entries) { + @override + Stream delete(Iterable entries) { try { return _opStreamChannel.receiveBroadcastStream({ 'op': 'delete', @@ -210,7 +294,8 @@ class ImageFileService { } } - static Stream move( + @override + Stream move( Iterable entries, { @required bool copy, @required String destinationAlbum, @@ -228,7 +313,8 @@ class ImageFileService { } } - static Stream export( + @override + Stream export( Iterable entries, { String mimeType = MimeTypes.jpeg, @required String destinationAlbum, @@ -246,7 +332,8 @@ class ImageFileService { } } - static Future rename(AvesEntry entry, String newName) async { + @override + Future rename(AvesEntry entry, String newName) async { try { // returns map with: 'contentId' 'path' 'title' 'uri' (all optional) final result = await platform.invokeMethod('rename', { @@ -260,7 +347,8 @@ class ImageFileService { return {}; } - static Future rotate(AvesEntry entry, {@required bool clockwise}) async { + @override + Future rotate(AvesEntry entry, {@required bool clockwise}) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('rotate', { @@ -274,7 +362,8 @@ class ImageFileService { return {}; } - static Future flip(AvesEntry entry) async { + @override + Future flip(AvesEntry entry) async { try { // returns map with: 'rotationDegrees' 'isFlipped' final result = await platform.invokeMethod('flip', { diff --git a/lib/services/media_store_service.dart b/lib/services/media_store_service.dart index 43f380358..edd6e134b 100644 --- a/lib/services/media_store_service.dart +++ b/lib/services/media_store_service.dart @@ -5,11 +5,21 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:streams_channel/streams_channel.dart'; -class MediaStoreService { +abstract class MediaStoreService { + Future> checkObsoleteContentIds(List knownContentIds); + + Future> checkObsoletePaths(Map knownPathById); + + // knownEntries: map of contentId -> dateModifiedSecs + Stream getEntries(Map knownEntries); +} + +class PlatformMediaStoreService implements MediaStoreService { static const platform = MethodChannel('deckers.thibault/aves/mediastore'); static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); - static Future> checkObsoleteContentIds(List knownContentIds) async { + @override + Future> checkObsoleteContentIds(List knownContentIds) async { try { final result = await platform.invokeMethod('checkObsoleteContentIds', { 'knownContentIds': knownContentIds, @@ -21,7 +31,8 @@ class MediaStoreService { return []; } - static Future> checkObsoletePaths(Map knownPathById) async { + @override + Future> checkObsoletePaths(Map knownPathById) async { try { final result = await platform.invokeMethod('checkObsoletePaths', { 'knownPathById': knownPathById, @@ -33,8 +44,8 @@ class MediaStoreService { return []; } - // knownEntries: map of contentId -> dateModifiedSecs - static Stream getEntries(Map knownEntries) { + @override + Stream getEntries(Map knownEntries) { try { return _streamChannel.receiveBroadcastStream({ 'knownEntries': knownEntries, diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 7954c1c84..83cedd445 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -8,11 +8,32 @@ import 'package:aves/services/service_policy.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -class MetadataService { +abstract class MetadataService { + // returns Map> (map of directories, each directory being a map of metadata label and value description) + Future getAllMetadata(AvesEntry entry); + + Future getCatalogMetadata(AvesEntry entry, {bool background = false}); + + Future getOverlayMetadata(AvesEntry entry); + + Future getMultiPageInfo(AvesEntry entry); + + Future getPanoramaInfo(AvesEntry entry); + + Future getContentResolverProp(AvesEntry entry, String prop); + + Future> getEmbeddedPictures(String uri); + + Future> getExifThumbnails(AvesEntry entry); + + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType); +} + +class PlatformMetadataService implements MetadataService { static const platform = MethodChannel('deckers.thibault/aves/metadata'); - // returns Map> (map of directories, each directory being a map of metadata label and value description) - static Future getAllMetadata(AvesEntry entry) async { + @override + Future getAllMetadata(AvesEntry entry) async { if (entry.isSvg) return null; try { @@ -28,7 +49,8 @@ class MetadataService { return {}; } - static Future getCatalogMetadata(AvesEntry entry, {bool background = false}) async { + @override + Future getCatalogMetadata(AvesEntry entry, {bool background = false}) async { if (entry.isSvg) return null; Future call() async { @@ -65,7 +87,8 @@ class MetadataService { : call(); } - static Future getOverlayMetadata(AvesEntry entry) async { + @override + Future getOverlayMetadata(AvesEntry entry) async { if (entry.isSvg) return null; try { @@ -82,7 +105,8 @@ class MetadataService { return null; } - static Future getMultiPageInfo(AvesEntry entry) async { + @override + Future getMultiPageInfo(AvesEntry entry) async { try { final result = await platform.invokeMethod('getMultiPageInfo', { 'mimeType': entry.mimeType, @@ -96,7 +120,8 @@ class MetadataService { return null; } - static Future getPanoramaInfo(AvesEntry entry) async { + @override + Future getPanoramaInfo(AvesEntry entry) async { try { // returns map with values for: // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), @@ -113,7 +138,8 @@ class MetadataService { return null; } - static Future getContentResolverProp(AvesEntry entry, String prop) async { + @override + Future getContentResolverProp(AvesEntry entry, String prop) async { try { return await platform.invokeMethod('getContentResolverProp', { 'mimeType': entry.mimeType, @@ -126,7 +152,8 @@ class MetadataService { return null; } - static Future> getEmbeddedPictures(String uri) async { + @override + Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { 'uri': uri, @@ -138,7 +165,8 @@ class MetadataService { return []; } - static Future> getExifThumbnails(AvesEntry entry) async { + @override + Future> getExifThumbnails(AvesEntry entry) async { try { final result = await platform.invokeMethod('getExifThumbnails', { 'mimeType': entry.mimeType, @@ -152,7 +180,8 @@ class MetadataService { return []; } - static Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { + @override + Future extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { try { final result = await platform.invokeMethod('extractXmpDataProp', { 'mimeType': entry.mimeType, diff --git a/lib/services/services.dart b/lib/services/services.dart new file mode 100644 index 000000000..e817e4cfa --- /dev/null +++ b/lib/services/services.dart @@ -0,0 +1,27 @@ +import 'package:aves/model/availability.dart'; +import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/media_store_service.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/time_service.dart'; +import 'package:get_it/get_it.dart'; + +final getIt = GetIt.instance; + +final availability = getIt(); +final metadataDb = getIt(); + +final imageFileService = getIt(); +final mediaStoreService = getIt(); +final metadataService = getIt(); +final timeService = getIt(); + +void initPlatformServices() { + getIt.registerLazySingleton(() => LiveAvesAvailability()); + getIt.registerLazySingleton(() => SqfliteMetadataDb()); + + getIt.registerLazySingleton(() => PlatformImageFileService()); + getIt.registerLazySingleton(() => PlatformMediaStoreService()); + getIt.registerLazySingleton(() => PlatformMetadataService()); + getIt.registerLazySingleton(() => PlatformTimeService()); +} diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart index bf8f79a9f..94fbf36a4 100644 --- a/lib/services/svg_metadata_service.dart +++ b/lib/services/svg_metadata_service.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:aves/model/entry.dart'; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -17,7 +17,7 @@ class SvgMetadataService { static Future getSize(AvesEntry entry) async { try { - final data = await ImageFileService.getSvg(entry.uri, entry.mimeType); + final data = await imageFileService.getSvg(entry.uri, entry.mimeType); final document = XmlDocument.parse(utf8.decode(data)); final root = document.rootElement; @@ -59,7 +59,7 @@ class SvgMetadataService { } try { - final data = await ImageFileService.getSvg(entry.uri, entry.mimeType); + final data = await imageFileService.getSvg(entry.uri, entry.mimeType); final document = XmlDocument.parse(utf8.decode(data)); final root = document.rootElement; diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index 07e8fb06e..d9b284bea 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -1,10 +1,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -class TimeService { +abstract class TimeService { + Future getDefaultTimeZone(); +} + +class PlatformTimeService implements TimeService { static const platform = MethodChannel('deckers.thibault/aves/time'); - static Future getDefaultTimeZone() async { + @override + Future getDefaultTimeZone() async { try { return await platform.invokeMethod('getDefaultTimeZone'); } on PlatformException catch (e) { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index bbbf6fd35..f0c590209 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -3,7 +3,6 @@ import 'package:flutter/scheduler.dart'; class Durations { // common animations static const iconAnimation = Duration(milliseconds: 300); - static const opToastAnimation = Duration(milliseconds: 600); static const sweeperOpacityAnimation = Duration(milliseconds: 150); static const sweepingAnimation = Duration(milliseconds: 650); static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration @@ -43,7 +42,7 @@ class Durations { static const xmpStructArrayCardTransition = Duration(milliseconds: 300); // delays & refresh intervals - static const opToastDisplay = Duration(seconds: 2); + static const opToastDisplay = Duration(seconds: 3); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const highlightScrollInitDelay = Duration(milliseconds: 800); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 2e7745856..13d002866 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -43,12 +43,12 @@ class AIcons { static const IconData openOutside = Icons.open_in_new_outlined; static const IconData pin = Icons.push_pin_outlined; static const IconData print = Icons.print_outlined; - static const IconData refresh = Icons.refresh_outlined; static const IconData rename = Icons.title_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData search = Icons.search_outlined; static const IconData select = Icons.select_all_outlined; + static const IconData setCover = MdiIcons.imageEditOutline; static const IconData share = Icons.share_outlined; static const IconData sort = Icons.sort_outlined; static const IconData stats = Icons.pie_chart_outlined; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart new file mode 100644 index 000000000..84d02ddf3 --- /dev/null +++ b/lib/theme/themes.dart @@ -0,0 +1,51 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class Themes { + static const _accentColor = Colors.indigoAccent; + + static final darkTheme = ThemeData( + brightness: Brightness.dark, + accentColor: _accentColor, + scaffoldBackgroundColor: Colors.grey[900], + buttonColor: _accentColor, + dialogBackgroundColor: Colors.grey[850], + toggleableActiveColor: _accentColor, + tooltipTheme: TooltipThemeData( + verticalOffset: 32, + ), + appBarTheme: AppBarTheme( + textTheme: TextTheme( + headline6: TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + fontFeatures: [FontFeature.enable('smcp')], + ), + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: Colors.grey[800], + contentTextStyle: TextStyle( + color: Colors.white, + ), + behavior: SnackBarBehavior.floating, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + primary: _accentColor, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + primary: _accentColor, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + primary: Colors.white, + ), + ), + ); +} diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index f0de8f962..3a5ca800b 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,6 +1,7 @@ import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:path/path.dart'; @@ -115,21 +116,30 @@ class Package { @immutable class StorageVolume { - final String description, path, state; + final String _description, path, state; final bool isPrimary, isRemovable; const StorageVolume({ - this.description, + String description, this.isPrimary, this.isRemovable, this.path, this.state, - }); + }) : _description = description; + + String getDescription(BuildContext context) { + if (_description != null) return _description; + // ideally, the context should always be provided, but in some cases (e.g. album comparison), + // this would require numerous additional methods to have the context as argument + // for such a minor benefit: fallback volume description on Android < N + if (isPrimary) return context?.l10n?.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage'; + return context?.l10n?.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card'; + } factory StorageVolume.fromMap(Map map) { final isPrimary = map['isPrimary'] ?? false; return StorageVolume( - description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'), + description: map['description'], isPrimary: isPrimary, isRemovable: map['isRemovable'] ?? false, path: map['path'] ?? '', @@ -167,11 +177,9 @@ class VolumeRelativeDirectory { ); } - String get directoryDescription => relativeDir.isEmpty ? 'root' : '“$relativeDir”'; - - String get volumeDescription { + String getVolumeDescription(BuildContext context) { final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null); - return volume?.description ?? volumePath; + return volume?.getDescription(context) ?? volumePath; } @override diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index bde527b73..e6c3248a8 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -21,7 +21,6 @@ class Constants { ); static const overlayUnknown = '—'; // em dash - static const infoUnknown = 'unknown'; static final pointNemo = LatLng(-48.876667, -123.393333); @@ -66,55 +65,13 @@ class Constants { ), ]; - static const List flutterPackages = [ - Dependency( - name: 'Flutter', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/flutter/flutter/blob/master/LICENSE', - sourceUrl: 'https://github.com/flutter/flutter', - ), - Dependency( - name: 'Charts', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE', - sourceUrl: 'https://github.com/google/charts', - ), - Dependency( - name: 'Collection', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE', - sourceUrl: 'https://github.com/dart-lang/collection', - ), + static const List flutterPlugins = [ Dependency( name: 'Connectivity', license: 'BSD 3-Clause', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity', ), - Dependency( - name: 'Country Code', - license: 'MIT', - licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE', - sourceUrl: 'https://github.com/denixport/dart.country', - ), - Dependency( - name: 'Decorated Icon', - license: 'MIT', - licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE', - sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', - ), - Dependency( - name: 'Event Bus', - license: 'MIT', - licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE', - sourceUrl: 'https://github.com/marcojakob/dart-event-bus', - ), - Dependency( - name: 'Expansion Tile Card', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/Skylled/expansion_tile_card/blob/master/LICENSE', - sourceUrl: 'https://github.com/Skylled/expansion_tile_card', - ), Dependency( name: 'FlutterFire (Core, Analytics, Crashlytics)', license: 'BSD 3-Clause', @@ -122,10 +79,160 @@ class Constants { sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', ), Dependency( - name: 'Flushbar', + name: 'Flutter ijkplayer', + license: 'MIT', + licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE', + sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer', + ), + Dependency( + name: 'Google API Availability', + license: 'MIT', + licenseUrl: 'https://github.com/Baseflow/flutter-google-api-availability/blob/master/LICENSE', + sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability', + ), + Dependency( + name: 'Google Maps for Flutter', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter', + ), + Dependency( + name: 'Package Info', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info', + ), + Dependency( + name: 'Permission Handler', + license: 'MIT', + licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', + sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', + ), + Dependency( + name: 'Printing', license: 'Apache 2.0', - licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE', - sourceUrl: 'https://github.com/AndreHaueisen/flushbar', + licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', + sourceUrl: 'https://github.com/DavBfr/dart_pdf', + ), + Dependency( + name: 'Shared Preferences', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences', + ), + Dependency( + name: 'sqflite', + license: 'MIT', + licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE', + sourceUrl: 'https://github.com/tekartik/sqflite', + ), + Dependency( + name: 'Streams Channel', + license: 'Apache 2.0', + licenseUrl: 'https://github.com/loup-v/streams_channel/blob/master/LICENSE', + sourceUrl: 'https://github.com/loup-v/streams_channel', + ), + Dependency( + name: 'URL Launcher', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', + ), + ]; + + static const List dartPackages = [ + Dependency( + name: 'Collection', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE', + sourceUrl: 'https://github.com/dart-lang/collection', + ), + Dependency( + name: 'Country Code', + license: 'MIT', + licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE', + sourceUrl: 'https://github.com/denixport/dart.country', + ), + Dependency( + name: 'Event Bus', + license: 'MIT', + licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE', + sourceUrl: 'https://github.com/marcojakob/dart-event-bus', + ), + Dependency( + name: 'Get It', + license: 'MIT', + licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE', + sourceUrl: 'https://github.com/fluttercommunity/get_it', + ), + Dependency( + name: 'Github', + license: 'MIT', + licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE', + sourceUrl: 'https://github.com/SpinlockLabs/github.dart', + ), + Dependency( + name: 'Intl', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE', + sourceUrl: 'https://github.com/dart-lang/intl', + ), + Dependency( + name: 'LatLong', + license: 'Apache 2.0', + licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE', + sourceUrl: 'https://github.com/MikeMitterer/dart-latlong', + ), + Dependency( + name: 'PDF for Dart and Flutter', + license: 'Apache 2.0', + licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', + sourceUrl: 'https://github.com/DavBfr/dart_pdf', + ), + Dependency( + name: 'Pedantic', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/dart-lang/pedantic/blob/master/LICENSE', + sourceUrl: 'https://github.com/dart-lang/pedantic', + ), + Dependency( + name: 'Tuple', + license: 'BSD 2-Clause', + licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE', + sourceUrl: 'https://github.com/dart-lang/tuple', + ), + Dependency( + name: 'Version', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE', + sourceUrl: 'https://github.com/dartninja/version', + ), + Dependency( + name: 'XML', + license: 'MIT', + licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE', + sourceUrl: 'https://github.com/renggli/dart-xml', + ), + ]; + + static const List flutterPackages = [ + Dependency( + name: 'Charts', + license: 'Apache 2.0', + licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE', + sourceUrl: 'https://github.com/google/charts', + ), + Dependency( + name: 'Decorated Icon', + license: 'MIT', + licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE', + sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', + ), + Dependency( + name: 'Expansion Tile Card', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/Skylled/expansion_tile_card/blob/master/LICENSE', + sourceUrl: 'https://github.com/Skylled/expansion_tile_card', ), Dependency( name: 'Flutter Highlight', @@ -134,10 +241,10 @@ class Constants { sourceUrl: 'https://github.com/git-touch/highlight', ), Dependency( - name: 'Flutter ijkplayer', + name: 'Flutter Localized Locales', license: 'MIT', - licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE', - sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer', + licenseUrl: 'https://github.com/guidezpl/flutter-localized-locales/blob/master/LICENSE', + sourceUrl: 'https://github.com/guidezpl/flutter-localized-locales', ), Dependency( name: 'Flutter Map', @@ -163,36 +270,6 @@ class Constants { licenseUrl: 'https://github.com/dnfield/flutter_svg/blob/master/LICENSE', sourceUrl: 'https://github.com/dnfield/flutter_svg', ), - Dependency( - name: 'Geocoder', - license: 'MIT', - licenseUrl: 'https://github.com/aloisdeniel/flutter_geocoder/blob/master/LICENSE', - sourceUrl: 'https://github.com/aloisdeniel/flutter_geocoder', - ), - Dependency( - name: 'Github', - license: 'MIT', - licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE', - sourceUrl: 'https://github.com/SpinlockLabs/github.dart', - ), - Dependency( - name: 'Google Maps for Flutter', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter', - ), - Dependency( - name: 'Intl', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE', - sourceUrl: 'https://github.com/dart-lang/intl', - ), - Dependency( - name: 'LatLong', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/MikeMitterer/dart-latlong/blob/master/LICENSE', - sourceUrl: 'https://github.com/MikeMitterer/dart-latlong', - ), Dependency( name: 'Material Design Icons Flutter', license: 'MIT', @@ -205,12 +282,6 @@ class Constants { licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE', sourceUrl: 'https://github.com/boyan01/overlay_support', ), - Dependency( - name: 'Package Info', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/package_info/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/package_info', - ), Dependency( name: 'Palette Generator', license: 'BSD 3-Clause', @@ -223,84 +294,18 @@ class Constants { licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE', sourceUrl: 'https://github.com/zesage/panorama', ), - Dependency( - name: 'PDF for Dart and Flutter', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', - sourceUrl: 'https://github.com/DavBfr/dart_pdf', - ), - Dependency( - name: 'Pedantic', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dart-lang/pedantic/blob/master/LICENSE', - sourceUrl: 'https://github.com/dart-lang/pedantic', - ), Dependency( name: 'Percent Indicator', license: 'BSD 2-Clause', licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE', sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/', ), - Dependency( - name: 'Permission Handler', - license: 'MIT', - licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', - sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', - ), - Dependency( - name: 'Printing', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', - sourceUrl: 'https://github.com/DavBfr/dart_pdf', - ), Dependency( name: 'Provider', license: 'MIT', licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE', sourceUrl: 'https://github.com/rrousselGit/provider', ), - Dependency( - name: 'Shared Preferences', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences', - ), - Dependency( - name: 'sqflite', - license: 'MIT', - licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE', - sourceUrl: 'https://github.com/tekartik/sqflite', - ), - Dependency( - name: 'Streams Channel', - license: 'Apache 2.0', - licenseUrl: 'https://github.com/loup-v/streams_channel/blob/master/LICENSE', - sourceUrl: 'https://github.com/loup-v/streams_channel', - ), - Dependency( - name: 'Tuple', - license: 'BSD 2-Clause', - licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE', - sourceUrl: 'https://github.com/dart-lang/tuple', - ), - Dependency( - name: 'URL Launcher', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', - ), - Dependency( - name: 'Version', - license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE', - sourceUrl: 'https://github.com/dartninja/version', - ), - Dependency( - name: 'XML', - license: 'MIT', - licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE', - sourceUrl: 'https://github.com/renggli/dart-xml', - ), ]; } diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index c65d2e6e0..d368bf332 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,7 +1,8 @@ import 'package:aves/widgets/about/app_ref.dart'; import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; -import 'package:aves/widgets/about/new_version.dart'; +import 'package:aves/widgets/about/update.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; class AboutPage extends StatelessWidget { @@ -11,7 +12,7 @@ class AboutPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('About'), + title: Text(context.l10n.aboutPageTitle), ), body: SafeArea( child: CustomScrollView( @@ -23,7 +24,7 @@ class AboutPage extends StatelessWidget { [ AppReference(), Divider(), - AboutNewVersion(), + AboutUpdate(), AboutCredits(), Divider(), ], diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart index 4a7f4b91c..0e383a192 100644 --- a/lib/widgets/about/app_ref.dart +++ b/lib/widgets/about/app_ref.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:aves/flutter_version.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:flutter/material.dart'; import 'package:package_info/package_info.dart'; @@ -48,7 +49,7 @@ class _AppReferenceState extends State { leading: AvesLogo( size: style.fontSize * MediaQuery.textScaleFactorOf(context) * 1.25, ), - text: 'Aves ${snapshot.data?.version}', + text: '${context.l10n.appName} ${snapshot.data?.version}', url: 'https://github.com/deckerst/aves', textStyle: style, ); @@ -71,7 +72,7 @@ class _AppReferenceState extends State { ), ), ), - TextSpan(text: 'Flutter ${version['frameworkVersion']}'), + TextSpan(text: '${context.l10n.aboutFlutter} ${version['frameworkVersion']}'), ], ), style: TextStyle(color: subColor), diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart index 55f2a5406..8cf4f1a5e 100644 --- a/lib/widgets/about/credits.dart +++ b/lib/widgets/about/credits.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; class AboutCredits extends StatelessWidget { @@ -16,13 +17,13 @@ class AboutCredits extends StatelessWidget { constraints: BoxConstraints(minHeight: 48), child: Align( alignment: AlignmentDirectional.centerStart, - child: Text('Credits', style: Constants.titleTextStyle), + child: Text(context.l10n.aboutCredits, style: Constants.titleTextStyle), ), ), Text.rich( TextSpan( children: [ - TextSpan(text: 'This app uses a TopoJSON file from'), + TextSpan(text: context.l10n.aboutCreditsWorldAtlas1), WidgetSpan( child: LinkChip( text: 'World Atlas', @@ -31,7 +32,7 @@ class AboutCredits extends StatelessWidget { ), alignment: PlaceholderAlignment.middle, ), - TextSpan(text: 'under ISC License.'), + TextSpan(text: context.l10n.aboutCreditsWorldAtlas2), ], ), ), diff --git a/lib/widgets/about/licenses.dart b/lib/widgets/about/licenses.dart index 7b1415212..7d4558417 100644 --- a/lib/widgets/about/licenses.dart +++ b/lib/widgets/about/licenses.dart @@ -3,6 +3,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -15,13 +16,15 @@ class Licenses extends StatefulWidget { class _LicensesState extends State { final ValueNotifier _expandedNotifier = ValueNotifier(null); LicenseSort _sort = LicenseSort.name; - List _platform, _flutter; + List _platform, _flutterPlugins, _flutterPackages, _dartPackages; @override void initState() { super.initState(); - _platform = List.from(Constants.androidDependencies); - _flutter = List.from(Constants.flutterPackages); + _platform = List.from(Constants.androidDependencies); + _flutterPlugins = List.from(Constants.flutterPlugins); + _flutterPackages = List.from(Constants.flutterPackages); + _dartPackages = List.from(Constants.dartPackages); _sortPackages(); } @@ -38,7 +41,9 @@ class _LicensesState extends State { } _platform.sort(compare); - _flutter.sort(compare); + _flutterPlugins.sort(compare); + _flutterPackages.sort(compare); + _dartPackages.sort(compare); } @override @@ -51,16 +56,28 @@ class _LicensesState extends State { _buildHeader(), SizedBox(height: 16), AvesExpansionTile( - title: 'Android Libraries', + title: context.l10n.aboutLicensesAndroidLibraries, color: BrandColors.android, expandedNotifier: _expandedNotifier, children: _platform.map((package) => LicenseRow(package)).toList(), ), AvesExpansionTile( - title: 'Flutter Packages', + title: context.l10n.aboutLicensesFlutterPlugins, color: BrandColors.flutter, expandedNotifier: _expandedNotifier, - children: _flutter.map((package) => LicenseRow(package)).toList(), + children: _flutterPlugins.map((package) => LicenseRow(package)).toList(), + ), + AvesExpansionTile( + title: context.l10n.aboutLicensesFlutterPackages, + color: BrandColors.flutter, + expandedNotifier: _expandedNotifier, + children: _flutterPackages.map((package) => LicenseRow(package)).toList(), + ), + AvesExpansionTile( + title: context.l10n.aboutLicensesDartPackages, + color: BrandColors.flutter, + expandedNotifier: _expandedNotifier, + children: _dartPackages.map((package) => LicenseRow(package)).toList(), ), Center( child: TextButton( @@ -76,7 +93,7 @@ class _LicensesState extends State { ), ), ), - child: Text('Show All Licenses'.toUpperCase()), + child: Text(context.l10n.aboutLicensesShowAllButtonLabel), ), ), ], @@ -94,17 +111,17 @@ class _LicensesState extends State { child: Row( children: [ Expanded( - child: Text('Open-Source Licenses', style: Constants.titleTextStyle), + child: Text(context.l10n.aboutLicenses, style: Constants.titleTextStyle), ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( value: LicenseSort.name, - child: MenuRow(text: 'Sort by name', checked: _sort == LicenseSort.name), + child: MenuRow(text: context.l10n.aboutLicensesSortByName, checked: _sort == LicenseSort.name), ), PopupMenuItem( value: LicenseSort.license, - child: MenuRow(text: 'Sort by license', checked: _sort == LicenseSort.license), + child: MenuRow(text: context.l10n.aboutLicensesSortByLicense, checked: _sort == LicenseSort.license), ), ], onSelected: (newSort) { @@ -112,7 +129,7 @@ class _LicensesState extends State { _sortPackages(); setState(() {}); }, - tooltip: 'Sort', + tooltip: context.l10n.aboutLicensesSortTooltip, icon: Icon(AIcons.sort), ), ], @@ -121,7 +138,7 @@ class _LicensesState extends State { SizedBox(height: 8), Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('The following sets forth attribution notices for third party software that may be contained in this application.'), + child: Text(context.l10n.aboutLicensesBanner), ), ], ); diff --git a/lib/widgets/about/new_version.dart b/lib/widgets/about/update.dart similarity index 72% rename from lib/widgets/about/new_version.dart rename to lib/widgets/about/update.dart index a5cb22fc7..02248105d 100644 --- a/lib/widgets/about/new_version.dart +++ b/lib/widgets/about/update.dart @@ -1,30 +1,31 @@ -import 'package:aves/model/availability.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/about/news_badge.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -class AboutNewVersion extends StatefulWidget { +class AboutUpdate extends StatefulWidget { @override - _AboutNewVersionState createState() => _AboutNewVersionState(); + _AboutUpdateState createState() => _AboutUpdateState(); } -class _AboutNewVersionState extends State { - Future _newVersionLoader; +class _AboutUpdateState extends State { + Future _updateChecker; @override void initState() { super.initState(); - _newVersionLoader = availability.isNewVersionAvailable; + _updateChecker = availability.isNewVersionAvailable; } @override Widget build(BuildContext context) { return FutureBuilder( - future: _newVersionLoader, + future: _updateChecker, builder: (context, snapshot) { - final newVersion = snapshot.data == true; - if (!newVersion) return SizedBox(); + final newVersionAvailable = snapshot.data == true; + if (!newVersionAvailable) return SizedBox(); return Column( children: [ Padding( @@ -46,7 +47,7 @@ class _AboutNewVersionState extends State { ), alignment: PlaceholderAlignment.middle, ), - TextSpan(text: 'New Version Available', style: Constants.titleTextStyle), + TextSpan(text: context.l10n.aboutUpdate, style: Constants.titleTextStyle), ], ), ), @@ -55,25 +56,25 @@ class _AboutNewVersionState extends State { Text.rich( TextSpan( children: [ - TextSpan(text: 'A new version of Aves is available on '), + TextSpan(text: context.l10n.aboutUpdateLinks1), WidgetSpan( child: LinkChip( - text: 'Github', + text: context.l10n.aboutUpdateGithub, url: 'https://github.com/deckerst/aves/releases', textStyle: TextStyle(fontWeight: FontWeight.bold), ), alignment: PlaceholderAlignment.middle, ), - TextSpan(text: ' and '), + TextSpan(text: context.l10n.aboutUpdateLinks2), WidgetSpan( child: LinkChip( - text: 'Google Play', + text: context.l10n.aboutUpdateGooglePlay, url: 'https://play.google.com/store/apps/details?id=deckers.thibault.aves', textStyle: TextStyle(fontWeight: FontWeight.bold), ), alignment: PlaceholderAlignment.middle, ), - TextSpan(text: '.'), + TextSpan(text: context.l10n.aboutUpdateLinks3), ], ), ), diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 4cc51796f..860b4b42a 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -1,8 +1,9 @@ import 'dart:async'; -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/collection_actions.dart'; import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -15,6 +16,7 @@ import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/search/search_button.dart'; @@ -23,8 +25,8 @@ import 'package:aves/widgets/stats/stats.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:intl/intl.dart'; import 'package:pedantic/pedantic.dart'; +import 'package:provider/provider.dart'; class CollectionAppBar extends StatefulWidget { final ValueNotifier appBarHeightNotifier; @@ -94,24 +96,29 @@ class _CollectionAppBarState extends State with SingleTickerPr @override Widget build(BuildContext context) { + final appMode = context.watch>().value; return ValueListenableBuilder( valueListenable: collection.activityNotifier, builder: (context, activity, child) { return AnimatedBuilder( animation: collection.filterChangeNotifier, - builder: (context, child) => SliverAppBar( - leading: _buildAppBarLeading(), - title: _buildAppBarTitle(), - actions: _buildActions(), - bottom: hasFilters - ? FilterBar( - filters: collection.filters, - onPressed: collection.removeFilter, - ) - : null, - titleSpacing: 0, - floating: true, - ), + builder: (context, child) { + final removableFilters = appMode != AppMode.pickInternal; + return SliverAppBar( + leading: appMode.hasDrawer ? _buildAppBarLeading() : null, + title: _buildAppBarTitle(), + actions: _buildActions(), + bottom: hasFilters + ? FilterBar( + filters: collection.filters, + removable: removableFilters, + onTap: removableFilters ? collection.removeFilter : null, + ) + : null, + titleSpacing: 0, + floating: true, + ); + }, ); }, ); @@ -140,18 +147,16 @@ class _CollectionAppBarState extends State with SingleTickerPr Widget _buildAppBarTitle() { if (collection.isBrowsing) { - Widget title = Text( - AvesApp.mode == AppMode.pick ? 'Pick' : 'Collection', - key: Key('appbar-title'), - ); - if (AvesApp.mode == AppMode.main) { + final appMode = context.watch>().value; + Widget title = Text(appMode.isPicking ? context.l10n.collectionPickPageTitle : context.l10n.collectionPageTitle); + if (appMode == AppMode.main) { title = SourceStateAwareAppBarTitle( title: title, source: source, ); } - return TappableAppBarTitle( - onTap: _goToSearch, + return InteractiveAppBarTitle( + onTap: appMode.canSearch ? _goToSearch : null, child: title, ); } else if (collection.isSelecting) { @@ -159,7 +164,7 @@ class _CollectionAppBarState extends State with SingleTickerPr animation: collection.selectionChangeNotifier, builder: (context, child) { final count = collection.selection.length; - return Text(Intl.plural(count, zero: 'Select items', one: '$count item', other: '$count items')); + return Text(context.l10n.collectionSelectionPageTitle(count)); }, ); } @@ -167,8 +172,9 @@ class _CollectionAppBarState extends State with SingleTickerPr } List _buildActions() { + final appMode = context.watch>().value; return [ - if (collection.isBrowsing) + if (collection.isBrowsing && appMode.canSearch) CollectionSearchButton( source, parentCollection: collection, @@ -180,7 +186,7 @@ class _CollectionAppBarState extends State with SingleTickerPr return IconButton( icon: Icon(action.getIcon()), onPressed: collection.selection.isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action), - tooltip: action.getText(), + tooltip: action.getText(context), ); }, )), @@ -197,35 +203,29 @@ class _CollectionAppBarState extends State with SingleTickerPr PopupMenuItem( key: Key('menu-sort'), value: CollectionAction.sort, - child: MenuRow(text: 'Sort…', icon: AIcons.sort), + child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort), ), if (collection.sortFactor == EntrySortFactor.date) PopupMenuItem( key: Key('menu-group'), value: CollectionAction.group, - child: MenuRow(text: 'Group…', icon: AIcons.group), + child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), + ), + if (collection.isBrowsing && appMode == AppMode.main) ...[ + PopupMenuItem( + value: CollectionAction.select, + enabled: isNotEmpty, + child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select), ), - if (collection.isBrowsing) ...[ - if (kDebugMode) - PopupMenuItem( - value: CollectionAction.refresh, - child: MenuRow(text: 'Refresh', icon: AIcons.refresh), - ), - if (AvesApp.mode == AppMode.main) - PopupMenuItem( - value: CollectionAction.select, - enabled: isNotEmpty, - child: MenuRow(text: 'Select', icon: AIcons.select), - ), PopupMenuItem( value: CollectionAction.stats, enabled: isNotEmpty, - child: MenuRow(text: 'Stats', icon: AIcons.stats), + child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats), ), - if (AvesApp.mode == AppMode.main && canAddShortcuts) + if (canAddShortcuts) PopupMenuItem( value: CollectionAction.addShortcut, - child: MenuRow(text: 'Add shortcut…', icon: AIcons.addShortcut), + child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut), ), ], if (collection.isSelecting) ...[ @@ -233,28 +233,28 @@ class _CollectionAppBarState extends State with SingleTickerPr PopupMenuItem( value: CollectionAction.copy, enabled: hasSelection, - child: MenuRow(text: 'Copy to album'), + child: MenuRow(text: context.l10n.collectionActionCopy), ), PopupMenuItem( value: CollectionAction.move, enabled: hasSelection, - child: MenuRow(text: 'Move to album'), + child: MenuRow(text: context.l10n.collectionActionMove), ), PopupMenuItem( value: CollectionAction.refreshMetadata, enabled: hasSelection, - child: MenuRow(text: 'Refresh metadata'), + child: MenuRow(text: context.l10n.collectionActionRefreshMetadata), ), PopupMenuDivider(), PopupMenuItem( value: CollectionAction.selectAll, enabled: collection.selection.length < collection.entryCount, - child: MenuRow(text: 'Select all'), + child: MenuRow(text: context.l10n.collectionActionSelectAll), ), PopupMenuItem( value: CollectionAction.selectNone, enabled: hasSelection, - child: MenuRow(text: 'Select none'), + child: MenuRow(text: context.l10n.collectionActionSelectNone), ), ] ]; @@ -289,9 +289,6 @@ class _CollectionAppBarState extends State with SingleTickerPr case CollectionAction.refreshMetadata: _actionDelegate.onCollectionActionSelected(context, action); break; - case CollectionAction.refresh: - unawaited(source.refresh()); - break; case CollectionAction.select: collection.select(); break; @@ -313,12 +310,12 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context) => AvesSelectionDialog( initialValue: settings.collectionGroupFactor, options: { - EntryGroupFactor.album: 'By album', - EntryGroupFactor.month: 'By month', - EntryGroupFactor.day: 'By day', - EntryGroupFactor.none: 'Do not group', + EntryGroupFactor.album: context.l10n.collectionGroupAlbum, + EntryGroupFactor.month: context.l10n.collectionGroupMonth, + EntryGroupFactor.day: context.l10n.collectionGroupDay, + EntryGroupFactor.none: context.l10n.collectionGroupNone, }, - title: 'Group', + title: context.l10n.collectionGroupTitle, ), ); if (value != null) { @@ -332,11 +329,11 @@ class _CollectionAppBarState extends State with SingleTickerPr builder: (context) => AvesSelectionDialog( initialValue: settings.collectionSortFactor, options: { - EntrySortFactor.date: 'By date', - EntrySortFactor.size: 'By size', - EntrySortFactor.name: 'By album & file name', + EntrySortFactor.date: context.l10n.collectionSortDate, + EntrySortFactor.size: context.l10n.collectionSortSize, + EntrySortFactor.name: context.l10n.collectionSortName, }, - title: 'Sort', + title: context.l10n.collectionSortTitle, ), ); if (value != null) { @@ -348,14 +345,24 @@ class _CollectionAppBarState extends State with SingleTickerPr } Future _showShortcutDialog(BuildContext context) async { + final filters = collection.filters; + var defaultName; + if (filters.isEmpty) { + defaultName = context.l10n.collectionPageTitle; + } else { + final sortedFilters = List.from(filters)..sort(); + defaultName = sortedFilters.first.getLabel(context); + } final name = await showDialog( context: context, - builder: (context) => AddShortcutDialog(collection.filters), + builder: (context) { + return AddShortcutDialog(defaultName: defaultName); + }, ); if (name == null || name.isEmpty) return; final iconEntry = collection.sortedEntries.isNotEmpty ? collection.sortedEntries.first : null; - unawaited(AppShortcutService.pin(name, iconEntry, collection.filters)); + unawaited(AppShortcutService.pin(name, iconEntry, filters)); } void _goToSearch() { diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/collection_grid.dart similarity index 50% rename from lib/widgets/collection/thumbnail_collection.dart rename to lib/widgets/collection/collection_grid.dart index 709a56686..2c0ebb4f7 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -1,21 +1,21 @@ import 'dart:async'; -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/app_bar.dart'; -import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; import 'package:aves/widgets/collection/grid/selector.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; +import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; @@ -23,152 +23,212 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; +import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/scaling.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; -class ThumbnailCollection extends StatelessWidget { - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); - final ValueNotifier _tileExtentNotifier = ValueNotifier(0); - final ValueNotifier _isScrollingNotifier = ValueNotifier(false); - final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); +class CollectionGrid extends StatefulWidget { + final String settingsRouteKey; - static const columnCountDefault = 4; - static const extentMin = 46.0; - static const spacing = 0.0; + const CollectionGrid({ + this.settingsRouteKey, + }); + + @override + _CollectionGridState createState() => _CollectionGridState(); +} + +class _CollectionGridState extends State { + TileExtentController _tileExtentController; @override Widget build(BuildContext context) { - return SafeArea( - bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final viewportSize = constraints.biggest; - assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); - if (viewportSize.isEmpty) return SizedBox.shrink(); - - final tileExtentManager = TileExtentManager( - settingsRouteKey: context.currentRouteName, - extentNotifier: _tileExtentNotifier, - columnCountDefault: columnCountDefault, - extentMin: extentMin, - spacing: spacing, - )..applyTileExtent(viewportSize: viewportSize); - final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; - final scrollController = PrimaryScrollController.of(context); - - // do not replace by Provider.of - // so that view updates on collection filter changes - return Consumer( - builder: (context, collection, child) { - final scrollView = AnimationLimiter( - child: CollectionScrollView( - scrollableKey: _scrollableKey, - collection: collection, - appBar: CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, - collection: collection, - ), - appBarHeightNotifier: _appBarHeightNotifier, - isScrollingNotifier: _isScrollingNotifier, - scrollController: scrollController, - cacheExtent: cacheExtent, - ), - ); - - final scaler = GridScaleGestureDetector( - tileExtentManager: tileExtentManager, - scrollableKey: _scrollableKey, - appBarHeightNotifier: _appBarHeightNotifier, - viewportSize: viewportSize, - gridBuilder: (center, extent, child) => CustomPaint( - // painting the thumbnail half-border on top of the grid yields artifacts, - // so we use a `foregroundPainter` to cover them instead - foregroundPainter: GridPainter( - center: center, - extent: extent, - spacing: tileExtentManager.spacing, - strokeWidth: DecoratedThumbnail.borderWidth * 2, - color: DecoratedThumbnail.borderColor, - ), - child: child, - ), - scaledBuilder: (entry, extent) => DecoratedThumbnail( - entry: entry, - extent: extent, - selectable: false, - highlightable: false, - ), - getScaledItemTileRect: (context, entry) { - final sectionedListLayout = context.read>(); - return sectionedListLayout.getTileRect(entry) ?? Rect.zero; - }, - onScaled: (entry) => context.read().set(entry), - child: scrollView, - ); - - final selector = GridSelectionGestureDetector( - selectable: AvesApp.mode == AppMode.main, - collection: collection, - scrollController: scrollController, - appBarHeightNotifier: _appBarHeightNotifier, - child: scaler, - ); - - final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider( - collection: collection, - scrollableWidth: viewportSize.width, - tileExtent: tileExtent, - columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), - tileBuilder: (entry) => InteractiveThumbnail( - key: ValueKey(entry.contentId), - collection: collection, - entry: entry, - tileExtent: tileExtent, - isScrollingNotifier: _isScrollingNotifier, - ), - child: selector, - ), - ); - return sectionedListLayoutProvider; - }, - ); - }, - ), + _tileExtentController ??= TileExtentController( + settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName, + columnCountDefault: 4, + extentMin: 46, + spacing: 0, + ); + return TileExtentControllerProvider( + controller: _tileExtentController, + child: _CollectionGridContent(), ); } } -class CollectionScrollView extends StatefulWidget { +class _CollectionGridContent extends StatelessWidget { + final ValueNotifier _isScrollingNotifier = ValueNotifier(false); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, collection, child) { + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: context.select>((controller) => controller.extentNotifier), + builder: (context, tileExtent, child) { + return ThumbnailTheme( + extent: tileExtent, + child: SectionedEntryListLayoutProvider( + collection: collection, + scrollableWidth: context.select((controller) => controller.viewportSize.width), + tileExtent: tileExtent, + columnCount: context.select((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)), + tileBuilder: (entry) => InteractiveThumbnail( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + tileExtent: tileExtent, + isScrollingNotifier: _isScrollingNotifier, + ), + child: _CollectionSectionedContent( + collection: collection, + isScrollingNotifier: _isScrollingNotifier, + scrollController: PrimaryScrollController.of(context), + ), + ), + ); + }, + ); + return sectionedListLayoutProvider; + }, + ); + } +} + +class _CollectionSectionedContent extends StatefulWidget { + final CollectionLens collection; + final ValueNotifier isScrollingNotifier; + final ScrollController scrollController; + + const _CollectionSectionedContent({ + @required this.collection, + @required this.isScrollingNotifier, + @required this.scrollController, + }); + + @override + _CollectionSectionedContentState createState() => _CollectionSectionedContentState(); +} + +class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { + CollectionLens get collection => widget.collection; + + ScrollController get scrollController => widget.scrollController; + + final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); + final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); + + @override + Widget build(BuildContext context) { + final scrollView = AnimationLimiter( + child: _CollectionScrollView( + scrollableKey: _scrollableKey, + collection: collection, + appBar: CollectionAppBar( + appBarHeightNotifier: _appBarHeightNotifier, + collection: collection, + ), + appBarHeightNotifier: _appBarHeightNotifier, + isScrollingNotifier: widget.isScrollingNotifier, + scrollController: scrollController, + ), + ); + + final scaler = _CollectionScaler( + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + child: scrollView, + ); + + final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); + final selector = GridSelectionGestureDetector( + selectable: isMainMode, + collection: collection, + scrollController: scrollController, + appBarHeightNotifier: _appBarHeightNotifier, + child: scaler, + ); + + return selector; + } +} + +class _CollectionScaler extends StatelessWidget { + final GlobalKey scrollableKey; + final ValueNotifier appBarHeightNotifier; + final Widget child; + + const _CollectionScaler({ + @required this.scrollableKey, + @required this.appBarHeightNotifier, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + final tileSpacing = context.select((controller) => controller.spacing); + return GridScaleGestureDetector( + scrollableKey: scrollableKey, + appBarHeightNotifier: appBarHeightNotifier, + gridBuilder: (center, extent, child) => CustomPaint( + // painting the thumbnail half-border on top of the grid yields artifacts, + // so we use a `foregroundPainter` to cover them instead + foregroundPainter: GridPainter( + center: center, + extent: extent, + spacing: tileSpacing, + strokeWidth: DecoratedThumbnail.borderWidth * 2, + color: DecoratedThumbnail.borderColor, + ), + child: child, + ), + scaledBuilder: (entry, extent) => ThumbnailTheme( + extent: extent, + child: DecoratedThumbnail( + entry: entry, + extent: extent, + selectable: false, + highlightable: false, + ), + ), + getScaledItemTileRect: (context, entry) { + final sectionedListLayout = context.read>(); + return sectionedListLayout.getTileRect(entry) ?? Rect.zero; + }, + onScaled: (entry) => context.read().set(entry), + child: child, + ); + } +} + +class _CollectionScrollView extends StatefulWidget { final GlobalKey scrollableKey; final CollectionLens collection; final Widget appBar; final ValueNotifier appBarHeightNotifier; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; - final double cacheExtent; - const CollectionScrollView({ + const _CollectionScrollView({ @required this.scrollableKey, @required this.collection, @required this.appBar, @required this.appBarHeightNotifier, @required this.isScrollingNotifier, @required this.scrollController, - @required this.cacheExtent, }); @override _CollectionScrollViewState createState() => _CollectionScrollViewState(); } -class _CollectionScrollViewState extends State { +class _CollectionScrollViewState extends State<_CollectionScrollView> { Timer _scrollMonitoringTimer; @override @@ -178,7 +238,7 @@ class _CollectionScrollViewState extends State { } @override - void didUpdateWidget(covariant CollectionScrollView oldWidget) { + void didUpdateWidget(covariant _CollectionScrollView oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); @@ -191,13 +251,13 @@ class _CollectionScrollViewState extends State { super.dispose(); } - void _registerWidget(CollectionScrollView widget) { + void _registerWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.addListener(_scrollToTop); widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop); widget.scrollController.addListener(_onScrollChange); } - void _unregisterWidget(CollectionScrollView widget) { + void _unregisterWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.removeListener(_scrollToTop); widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop); widget.scrollController.removeListener(_onScrollChange); @@ -209,27 +269,6 @@ class _CollectionScrollViewState extends State { return _buildDraggableScrollView(scrollView); } - ScrollView _buildScrollView(Widget appBar, CollectionLens collection) { - return CustomScrollView( - key: widget.scrollableKey, - primary: true, - // workaround to prevent scrolling the app bar away - // when there is no content and we use `SliverFillRemaining` - physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - cacheExtent: widget.cacheExtent, - slivers: [ - appBar, - collection.isEmpty - ? SliverFillRemaining( - child: _buildEmptyCollectionPlaceholder(collection), - hasScrollBody: false, - ) - : SectionedListSliver(), - BottomPaddingSliver(), - ], - ); - } - Widget _buildDraggableScrollView(ScrollView scrollView) { return ValueListenableBuilder( valueListenable: widget.appBarHeightNotifier, @@ -255,6 +294,27 @@ class _CollectionScrollViewState extends State { ); } + ScrollView _buildScrollView(Widget appBar, CollectionLens collection) { + return CustomScrollView( + key: widget.scrollableKey, + primary: true, + // workaround to prevent scrolling the app bar away + // when there is no content and we use `SliverFillRemaining` + physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + cacheExtent: context.select((controller) => controller.effectiveExtentMax * 2), + slivers: [ + appBar, + collection.isEmpty + ? SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyCollectionPlaceholder(collection), + ) + : SectionedListSliver(), + BottomPaddingSliver(), + ], + ); + } + Widget _buildEmptyCollectionPlaceholder(CollectionLens collection) { return ValueListenableBuilder( valueListenable: collection.source.stateNotifier, @@ -265,18 +325,18 @@ class _CollectionScrollViewState extends State { if (collection.filters.any((filter) => filter is FavouriteFilter)) { return EmptyContent( icon: AIcons.favourite, - text: 'No favourites', + text: context.l10n.collectionEmptyFavourites, ); } if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) { return EmptyContent( icon: AIcons.video, - text: 'No videos', + text: context.l10n.collectionEmptyVideos, ); } return EmptyContent( icon: AIcons.image, - text: 'No images', + text: context.l10n.collectionEmptyImages, ); }, ); diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 710b62848..6c8704dc7 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,5 +1,5 @@ import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/collection/thumbnail_collection.dart'; +import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -31,26 +31,29 @@ class _CollectionPageState extends State { @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: ChangeNotifierProvider.value( - value: collection, - child: Scaffold( - body: WillPopScope( - onWillPop: () { - if (collection.isSelecting) { - collection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: ThumbnailCollection(), + child: Scaffold( + body: WillPopScope( + onWillPop: () { + if (collection.isSelecting) { + collection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: CollectionGrid(), + ), ), ), ), - drawer: AppDrawer(), - resizeToAvoidBottomInset: false, ), + drawer: AppDrawer(), + resizeToAvoidBottomInset: false, ), ); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index d8a4544fd..eacaa1810 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -8,18 +8,18 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_file_service.dart'; -import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_op_events.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:intl/intl.dart'; class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { final CollectionLens collection; @@ -99,33 +99,31 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final copy = moveType == MoveType.copy; final todoCount = todoEntries.length; - // while the move is ongoing, source monitoring may remove entries from itself and the favourites repo - // so we save favourites beforehand, and will mark the moved entries as such after the move - final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet(); source.pauseMonitoring(); showOpReport( context: context, - opStream: ImageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), + opStream: imageFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), itemCount: todoCount, onDone: (processed) async { final movedOps = processed.where((e) => e.success).toSet(); - final movedCount = movedOps.length; - if (movedCount < todoCount) { - final count = todoCount - movedCount; - showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}'); - } else { - final count = movedCount; - showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}'); - } await source.updateAfterMove( todoEntries: todoEntries, - favouriteEntries: favouriteEntries, copy: copy, destinationAlbum: destinationAlbum, movedOps: movedOps, ); collection.browse(); source.resumeMonitoring(); + + final l10n = context.l10n; + final movedCount = movedOps.length; + if (movedCount < todoCount) { + final count = todoCount - movedCount; + showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); + } else { + final count = movedCount; + showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count)); + } }, ); } @@ -138,15 +136,15 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware builder: (context) { return AvesDialog( context: context, - content: Text('Are you sure you want to delete ${Intl.plural(count, one: 'this item', other: 'these $count items')}?'), + content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(count)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('Delete'.toUpperCase()), + child: Text(context.l10n.deleteButtonLabel), ), ], ); @@ -160,18 +158,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware source.pauseMonitoring(); showOpReport( context: context, - opStream: ImageFileService.delete(selection), + opStream: imageFileService.delete(selection), itemCount: selectionCount, - onDone: (processed) { + onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + await source.removeEntries(deletedUris); + collection.browse(); + source.resumeMonitoring(); + final deletedCount = deletedUris.length; if (deletedCount < selectionCount) { final count = selectionCount - deletedCount; - showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); + showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); } - source.removeEntries(deletedUris); - collection.browse(); - source.resumeMonitoring(); }, ); } diff --git a/lib/widgets/collection/filter_bar.dart b/lib/widgets/collection/filter_bar.dart index 10e922443..8fdc8cddd 100644 --- a/lib/widgets/collection/filter_bar.dart +++ b/lib/widgets/collection/filter_bar.dart @@ -8,13 +8,15 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { static const double preferredHeight = AvesFilterChip.minChipHeight + verticalPadding; final List filters; - final FilterCallback onPressed; + final bool removable; + final FilterCallback onTap; FilterBar({ Key key, @required Set filters, - @required this.onPressed, - }) : filters = List.from(filters)..sort(), + @required this.removable, + this.onTap, + }) : filters = List.from(filters)..sort(), super(key: key); @override @@ -26,7 +28,9 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget { class _FilterBarState extends State { final GlobalKey _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list'); - CollectionFilter _userRemovedFilter; + CollectionFilter _userTappedFilter; + + FilterCallback get onTap => widget.onTap; @override void didUpdateWidget(covariant FilterBar oldWidget) { @@ -41,7 +45,7 @@ class _FilterBarState extends State { existing.removeAt(index); // only animate item removal when triggered by a user interaction with the chip, // not from automatic chip replacement following chip selection - final animate = _userRemovedFilter == filter; + final animate = _userTappedFilter == filter; listState.removeItem( index, animate @@ -70,7 +74,7 @@ class _FilterBarState extends State { duration: Duration.zero, ); }); - _userRemovedFilter = null; + _userTappedFilter = null; } @override @@ -106,12 +110,14 @@ class _FilterBarState extends State { child: AvesFilterChip( key: ValueKey(filter), filter: filter, - removable: true, + removable: widget.removable, heroType: HeroType.always, - onTap: (filter) { - _userRemovedFilter = filter; - widget.onPressed(filter); - }, + onTap: onTap != null + ? (filter) { + _userTappedFilter = filter; + onTap(filter); + } + : null, ), ), ); diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index cf67b3b6d..5dcfe766c 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -9,12 +9,11 @@ import 'package:flutter/material.dart'; class AlbumSectionHeader extends StatelessWidget { final String directory, albumName; - AlbumSectionHeader({ + const AlbumSectionHeader({ Key key, - @required CollectionSource source, @required this.directory, - }) : albumName = source.getUniqueAlbumName(directory), - super(key: key); + @required this.albumName, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -47,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget { return SectionHeader.getPreferredHeight( context: context, maxWidth: maxWidth, - title: source.getUniqueAlbumName(directory), + title: source.getUniqueAlbumName(context, directory), hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular, hasTrailing: androidFileUtils.isOnRemovableStorage(directory), ); diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 7ed5bf63c..e323ba087 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -23,7 +23,7 @@ class CollectionSectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final header = _buildHeader(); + final header = _buildHeader(context); return header != null ? SizedBox( height: height, @@ -32,18 +32,12 @@ class CollectionSectionHeader extends StatelessWidget { : SizedBox.shrink(); } - Widget _buildHeader() { - Widget _buildAlbumHeader() => AlbumSectionHeader( - key: ValueKey(sectionKey), - source: collection.source, - directory: (sectionKey as EntryAlbumSectionKey).directory, - ); - + Widget _buildHeader(BuildContext context) { switch (collection.sortFactor) { case EntrySortFactor.date: switch (collection.groupFactor) { case EntryGroupFactor.album: - return _buildAlbumHeader(); + return _buildAlbumHeader(context); case EntryGroupFactor.month: return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date); case EntryGroupFactor.day: @@ -53,13 +47,23 @@ class CollectionSectionHeader extends StatelessWidget { } break; case EntrySortFactor.name: - return _buildAlbumHeader(); + return _buildAlbumHeader(context); case EntrySortFactor.size: break; } return null; } + Widget _buildAlbumHeader(BuildContext context) { + final source = collection.source; + final directory = (sectionKey as EntryAlbumSectionKey).directory; + return AlbumSectionHeader( + key: ValueKey(sectionKey), + directory: directory, + albumName: source.getUniqueAlbumName(context, directory), + ); + } + static double getPreferredHeight(BuildContext context, double maxWidth, CollectionSource source, SectionKey sectionKey) { var headerExtent = 0.0; if (sectionKey is EntryAlbumSectionKey) { diff --git a/lib/widgets/collection/grid/headers/date.dart b/lib/widgets/collection/grid/headers/date.dart index 8de36ce61..2e09e7874 100644 --- a/lib/widgets/collection/grid/headers/date.dart +++ b/lib/widgets/collection/grid/headers/date.dart @@ -1,73 +1,79 @@ import 'package:aves/model/source/section_keys.dart'; import 'package:aves/utils/time_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/header.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; class DaySectionHeader extends StatelessWidget { final DateTime date; - final String text; - DaySectionHeader({ + const DaySectionHeader({ Key key, @required this.date, - }) : text = _formatDate(date), - super(key: key); + }) : super(key: key); // Examples (en_US): - // `MMMMd`: `April 15` - // `yMMMMd`: `April 15, 2020` - // `MMMEd`: `Wed, Apr 15` - // `yMMMEd`: `Wed, Apr 15, 2020` - // `MMMMEEEEd`: `Wednesday, April 15` - // `yMMMMEEEEd`: `Wednesday, April 15, 2020` - // `MEd`: `Wed, 4/15` - // `yMEd`: `Wed, 4/15/2020` - static DateFormat md = DateFormat.MMMMd(); - static DateFormat ymd = DateFormat.yMMMMd(); - static DateFormat day = DateFormat.E(); + // `MMMMd`: `April 15` + // `yMMMMd`: `April 15, 2020` + // `MMMEd`: `Wed, Apr 15` + // `yMMMEd`: `Wed, Apr 15, 2020` + // `MMMMEEEEd`: `Wednesday, April 15` + // `yMMMMEEEEd`: `Wednesday, April 15, 2020` + // `MEd`: `Wed, 4/15` + // `yMEd`: `Wed, 4/15/2020` - static String _formatDate(DateTime date) { - if (date.isToday) return 'Today'; - if (date.isYesterday) return 'Yesterday'; - if (date.isThisYear) return '${md.format(date)} (${day.format(date)})'; - return '${ymd.format(date)} (${day.format(date)})'; + // Examples (ko): + // `MMMMd`: `1월 26일` + // `yMMMMd`: `2021년 1월 26일` + // `MMMEd`: `1월 26일 (화)` + // `yMMMEd`: `2021년 1월 26일 (화)` + // `MMMMEEEEd`: `1월 26일 화요일` + // `yMMMMEEEEd`: `2021년 1월 26일 화요일` + // `MEd`: `1. 26. (화)` + // `yMEd`: `2021. 1. 26. (화)` + + static String _formatDate(BuildContext context, DateTime date) { + final l10n = context.l10n; + if (date == null) return l10n.sectionUnknown; + if (date.isToday) return l10n.dateToday; + if (date.isYesterday) return l10n.dateYesterday; + final locale = l10n.localeName; + if (date.isThisYear) return '${DateFormat.MMMMd(locale).format(date)} (${DateFormat.E(locale).format(date)})'; + return '${DateFormat.yMMMMd(locale).format(date)} (${DateFormat.E(locale).format(date)})'; } @override Widget build(BuildContext context) { return SectionHeader( sectionKey: EntryDateSectionKey(date), - title: text, + title: _formatDate(context, date), ); } } class MonthSectionHeader extends StatelessWidget { final DateTime date; - final String text; - MonthSectionHeader({ + const MonthSectionHeader({ Key key, @required this.date, - }) : text = _formatDate(date), - super(key: key); + }) : super(key: key); - static DateFormat m = DateFormat.MMMM(); - static DateFormat ym = DateFormat.yMMMM(); - - static String _formatDate(DateTime date) { - if (date == null) return 'Unknown'; - if (date.isThisMonth) return 'This month'; - if (date.isThisYear) return m.format(date); - return ym.format(date); + static String _formatDate(BuildContext context, DateTime date) { + final l10n = context.l10n; + if (date == null) return l10n.sectionUnknown; + if (date.isThisMonth) return l10n.dateThisMonth; + final locale = l10n.localeName; + if (date.isThisYear) return DateFormat.MMMM(locale).format(date); + return DateFormat.yMMMM(locale).format(date); } @override Widget build(BuildContext context) { return SectionHeader( sectionKey: EntryDateSectionKey(date), - title: text, + title: _formatDate(context, date), ); } } diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 09dcd14ca..91be8ed35 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -1,4 +1,4 @@ -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; @@ -7,6 +7,7 @@ import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class InteractiveThumbnail extends StatelessWidget { final CollectionLens collection; @@ -27,14 +28,23 @@ class InteractiveThumbnail extends StatelessWidget { return GestureDetector( key: ValueKey(entry.uri), onTap: () { - if (AvesApp.mode == AppMode.main) { - if (collection.isBrowsing) { - _goToViewer(context); - } else if (collection.isSelecting) { - collection.toggleSelection(entry); - } - } else if (AvesApp.mode == AppMode.pick) { - ViewerService.pick(entry.uri); + final appMode = context.read>().value; + switch (appMode) { + case AppMode.main: + if (collection.isBrowsing) { + _goToViewer(context); + } else if (collection.isSelecting) { + collection.toggleSelection(entry); + } + break; + case AppMode.pickExternal: + ViewerService.pick(entry.uri); + break; + case AppMode.pickInternal: + Navigator.pop(context, entry); + break; + case AppMode.view: + break; } }, child: MetaData( diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 2a4fa81b9..5ecc75d95 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -31,7 +31,8 @@ class DecoratedThumbnail extends StatelessWidget { // between different views of the entry in the same collection (e.g. thumbnails <-> viewer) // but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer) final heroTag = hashValues(collection?.id, entry); - var child = entry.isSvg + final isSvg = entry.isSvg; + var child = isSvg ? VectorImageThumbnail( entry: entry, extent: extent, @@ -45,17 +46,14 @@ class DecoratedThumbnail extends StatelessWidget { ); child = Stack( - alignment: Alignment.center, + alignment: isSvg ? Alignment.center : AlignmentDirectional.bottomStart, children: [ child, - Positioned( - bottom: 0, - left: 0, - child: ThumbnailEntryOverlay( + if (!isSvg) + ThumbnailEntryOverlay( entry: entry, extent: extent, ), - ), if (selectable) ThumbnailSelectionOverlay( entry: entry, diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 86dc5edb7..26ee3c736 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -1,17 +1,16 @@ import 'dart:math'; -import 'package:aves/model/highlight.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class ThumbnailEntryOverlay extends StatelessWidget { final AvesEntry entry; @@ -25,38 +24,28 @@ class ThumbnailEntryOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - final fontSize = min(14.0, (extent / 8)).roundToDouble(); - final iconSize = fontSize * 2; - return Selector>( - selector: (context, s) => Tuple3(s.showThumbnailLocation, s.showThumbnailRaw, s.showThumbnailVideoDuration), - builder: (context, s, child) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (entry.hasGps && settings.showThumbnailLocation) GpsIcon(iconSize: iconSize), - if (entry.isRaw && settings.showThumbnailRaw) RawIcon(iconSize: iconSize), - if (entry.isMultipage) MultipageIcon(iconSize: iconSize), - if (entry.isGeotiff) GeotiffIcon(iconSize: iconSize), - if (entry.isAnimated) - AnimatedImageIcon(iconSize: iconSize) - else if (entry.isVideo) - DefaultTextStyle( - style: TextStyle( - color: Colors.grey[200], - fontSize: fontSize, - ), - child: VideoIcon( - entry: entry, - iconSize: iconSize, - showDuration: settings.showThumbnailVideoDuration, - ), - ) - else if (entry.is360) - SphericalImageIcon(iconSize: iconSize), - ], - ); - }); + final children = [ + if (entry.hasGps && context.select((t) => t.showLocation)) GpsIcon(), + if (entry.isVideo) + VideoIcon( + entry: entry, + ) + else if (entry.isAnimated) + AnimatedImageIcon() + else ...[ + if (entry.isRaw && context.select((t) => t.showRaw)) RawIcon(), + if (entry.isMultipage) MultipageIcon(), + if (entry.isGeotiff) GeotiffIcon(), + if (entry.is360) SphericalImageIcon(), + ] + ]; + if (children.isEmpty) return SizedBox.shrink(); + if (children.length == 1) return children.first; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ); } } @@ -64,6 +53,8 @@ class ThumbnailSelectionOverlay extends StatelessWidget { final AvesEntry entry; final double extent; + static const duration = Durations.thumbnailOverlayAnimation; + const ThumbnailSelectionOverlay({ Key key, @required this.entry, @@ -72,9 +63,6 @@ class ThumbnailSelectionOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - const duration = Durations.thumbnailOverlayAnimation; - final fontSize = min(14.0, (extent / 8)).roundToDouble(); - final iconSize = fontSize * 2; final collection = context.watch(); return ValueListenableBuilder( valueListenable: collection.activityNotifier, @@ -88,7 +76,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget { ? OverlayIcon( key: ValueKey(selected), icon: selected ? AIcons.selected : AIcons.unselected, - size: iconSize, + size: context.select((t) => t.iconSize), ) : SizedBox.shrink(); child = AnimatedSwitcher( @@ -139,6 +127,8 @@ class _ThumbnailHighlightOverlayState extends State { AvesEntry get entry => widget.entry; + static const startAngle = pi * -3 / 4; + @override Widget build(BuildContext context) { final highlightInfo = context.watch(); @@ -153,7 +143,7 @@ class _ThumbnailHighlightOverlayState extends State { ), ), toggledNotifier: _highlightedNotifier, - startAngle: pi * -3 / 4, + startAngle: startAngle, centerSweep: false, onSweepEnd: highlightInfo.clear, ); diff --git a/lib/widgets/collection/thumbnail/raster.dart b/lib/widgets/collection/thumbnail/raster.dart index e7ac7c516..400fa5b8d 100644 --- a/lib/widgets/collection/thumbnail/raster.dart +++ b/lib/widgets/collection/thumbnail/raster.dart @@ -3,6 +3,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/error.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/transition_image.dart'; import 'package:flutter/material.dart'; @@ -79,7 +80,7 @@ class _RasterImageThumbnailState extends State { @override Widget build(BuildContext context) { if (!entry.canDecode) { - return _buildError(context, '${entry.mimeType} not supported', null); + return _buildError(context, context.l10n.errorUnsupportedMimeType(entry.mimeType), null); } final fastImage = Image( diff --git a/lib/widgets/collection/thumbnail/theme.dart b/lib/widgets/collection/thumbnail/theme.dart new file mode 100644 index 000000000..83bd64c28 --- /dev/null +++ b/lib/widgets/collection/thumbnail/theme.dart @@ -0,0 +1,46 @@ +import 'dart:math'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ThumbnailTheme extends StatelessWidget { + final double extent; + final Widget child; + + const ThumbnailTheme({ + @required this.extent, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return ProxyProvider( + update: (_, settings, __) { + final iconSize = min(28.0, (extent / 4)).roundToDouble(); + final fontSize = (iconSize / 2).floorToDouble(); + return ThumbnailThemeData( + iconSize: iconSize, + fontSize: fontSize, + showLocation: settings.showThumbnailLocation, + showRaw: settings.showThumbnailRaw, + showVideoDuration: settings.showThumbnailVideoDuration, + ); + }, + child: child, + ); + } +} + +class ThumbnailThemeData { + final double iconSize, fontSize; + final bool showLocation, showRaw, showVideoDuration; + + const ThumbnailThemeData({ + @required this.iconSize, + @required this.fontSize, + @required this.showLocation, + @required this.showRaw, + @required this.showVideoDuration, + }); +} diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index d9d379ff5..099780779 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -1,6 +1,7 @@ import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 06dcbce87..4600de46c 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -1,26 +1,21 @@ import 'package:aves/theme/durations.dart'; -import 'package:flushbar/flushbar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:intl/intl.dart'; import 'package:percent_indicator/circular_percent_indicator.dart'; mixin FeedbackMixin { - Flushbar _flushbar; - - Future dismissFeedback() => _flushbar?.dismiss(); + void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar(); void showFeedback(BuildContext context, String message) { - _flushbar = Flushbar( - message: message, - margin: EdgeInsets.all(8), - borderRadius: 8, - borderColor: Colors.white30, - borderWidth: 0.5, - duration: Durations.opToastDisplay * timeDilation, - flushbarPosition: FlushbarPosition.TOP, - animationDuration: Durations.opToastAnimation, - )..show(context); + showFeedbackWithMessenger(ScaffoldMessenger.of(context), message); + } + + // provide the messenger if feedback happens as the widget is disposed + void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message) { + messenger.showSnackBar(SnackBar( + content: Text(message), + duration: Durations.opToastDisplay, + )); } // report overlay for multiple operations diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index cbd8ce7c9..5f74e95fc 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; @@ -26,18 +27,20 @@ mixin PermissionAwareMixin { final confirmed = await showDialog( context: context, builder: (context) { + final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); + final volume = dir.getVolumeDescription(context); return AvesDialog( context: context, - title: 'Storage Volume Access', - content: Text('Please select the ${dir.directoryDescription} directory of “${dir.volumeDescription}” in the next screen, so that this app can access it and complete your request.'), + title: context.l10n.storageAccessDialogTitle, + content: Text(context.l10n.storageAccessDialogMessage(directory, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('OK'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], ); @@ -58,14 +61,16 @@ mixin PermissionAwareMixin { return showDialog( context: context, builder: (context) { + final directory = dir.relativeDir.isEmpty ? context.l10n.rootDirectoryDescription : context.l10n.otherDirectoryDescription(dir.relativeDir); + final volume = dir.getVolumeDescription(context); return AvesDialog( context: context, - title: 'Restricted Access', - content: Text('This app is not allowed to modify files in the ${dir.directoryDescription} directory of “${dir.volumeDescription}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.'), + title: context.l10n.restrictedAccessDialogTitle, + content: Text(context.l10n.restrictedAccessDialogMessage(directory, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('OK'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], ); diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index bf6e34b44..b3584fa37 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -6,6 +6,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/services/android_file_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -43,14 +44,17 @@ mixin SizeAwareMixin { await showDialog( context: context, builder: (context) { + final neededSize = formatFilesize(needed); + final freeSize = formatFilesize(free); + final volume = destinationVolume.getDescription(context); return AvesDialog( context: context, - title: 'Not Enough Space', - content: Text('This operation needs ${formatFilesize(needed)} of free space on “${destinationVolume.description}” to complete, but there is only ${formatFilesize(free)} left.'), + title: context.l10n.notEnoughSpaceDialogTitle, + content: Text(context.l10n.notEnoughSpaceDialogMessage(neededSize, freeSize, volume)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('OK'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], ); diff --git a/lib/widgets/common/app_bar_subtitle.dart b/lib/widgets/common/app_bar_subtitle.dart index 8b44a7520..575a92d95 100644 --- a/lib/widgets/common/app_bar_subtitle.dart +++ b/lib/widgets/common/app_bar_subtitle.dart @@ -1,5 +1,7 @@ import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; class SourceStateAwareAppBarTitle extends StatelessWidget { @@ -54,13 +56,13 @@ class SourceStateSubtitle extends StatelessWidget { String subtitle; switch (source.stateNotifier.value) { case SourceState.loading: - subtitle = 'Loading'; + subtitle = context.l10n.sourceStateLoading; break; case SourceState.cataloguing: - subtitle = 'Cataloguing'; + subtitle = context.l10n.sourceStateCataloguing; break; case SourceState.locating: - subtitle = 'Locating'; + subtitle = context.l10n.sourceStateLocating; break; case SourceState.ready: default: diff --git a/lib/widgets/common/app_bar_title.dart b/lib/widgets/common/app_bar_title.dart index d00745b8c..ed285d341 100644 --- a/lib/widgets/common/app_bar_title.dart +++ b/lib/widgets/common/app_bar_title.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -class TappableAppBarTitle extends StatelessWidget { +class InteractiveAppBarTitle extends StatelessWidget { final GestureTapCallback onTap; final Widget child; - const TappableAppBarTitle({ + const InteractiveAppBarTitle({ this.onTap, @required this.child, }); diff --git a/lib/widgets/common/basic/draggable_scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar.dart index c2e8d3292..aea685017 100644 --- a/lib/widgets/common/basic/draggable_scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar.dart @@ -88,8 +88,8 @@ class DraggableScrollbar extends StatefulWidget { children: [ ScrollLabel( animation: labelAnimation, - child: labelText, backgroundColor: backgroundColor, + child: labelText, ), scrollThumb, ], diff --git a/lib/widgets/common/basic/query_bar.dart b/lib/widgets/common/basic/query_bar.dart index 570e4589f..aa3834857 100644 --- a/lib/widgets/common/basic/query_bar.dart +++ b/lib/widgets/common/basic/query_bar.dart @@ -1,6 +1,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -34,7 +35,7 @@ class _QueryBarState extends State { _controller.clear(); filterNotifier.value = ''; }, - tooltip: 'Clear', + tooltip: context.l10n.clearTooltip, ); return Row( diff --git a/lib/widgets/common/behaviour/double_back_pop.dart b/lib/widgets/common/behaviour/double_back_pop.dart index 4a844fdda..e3aa98d5c 100644 --- a/lib/widgets/common/behaviour/double_back_pop.dart +++ b/lib/widgets/common/behaviour/double_back_pop.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:overlay_support/overlay_support.dart'; @@ -37,7 +38,7 @@ class _DoubleBackPopScopeState extends State with FeedbackMi _stopBackTimer(); _backTimer = Timer(Durations.doubleBackTimerDelay, () => _backOnce = false); toast( - 'Tap “back” again to exit.', + context.l10n.doubleBackExitMessage, duration: Durations.doubleBackTimerDelay, ); return SynchronousFuture(false); diff --git a/lib/widgets/common/extensions/build_context.dart b/lib/widgets/common/extensions/build_context.dart index 3f06767b8..bd79ec90f 100644 --- a/lib/widgets/common/extensions/build_context.dart +++ b/lib/widgets/common/extensions/build_context.dart @@ -1,5 +1,8 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; extension ExtraContext on BuildContext { String get currentRouteName => ModalRoute.of(this)?.settings?.name; + + AppLocalizations get l10n => AppLocalizations.of(this); } diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 3c02d47f2..70ba4b054 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class AvesCircleBorder { static const borderColor = Colors.white30; - static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0; + static double _borderWidth(BuildContext context) => context.read().devicePixelRatio > 2 ? 0.5 : 1.0; static Border build(BuildContext context) { return Border.fromBorderSide(buildSide(context)); diff --git a/lib/widgets/common/fx/sweeper.dart b/lib/widgets/common/fx/sweeper.dart index 0e9be29ab..01221faf5 100644 --- a/lib/widgets/common/fx/sweeper.dart +++ b/lib/widgets/common/fx/sweeper.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/theme/durations.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:pedantic/pedantic.dart'; class Sweeper extends StatefulWidget { final WidgetBuilder builder; @@ -89,11 +90,11 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { valueListenable: _angleAnimationController, builder: (context, value, child) { return ClipPath( - child: widget.builder(context), clipper: _SweepClipPath( startAngle: _angle.value, sweepAngle: widget.sweepAngle, ), + child: widget.builder(context), ); }), ), @@ -115,7 +116,7 @@ class _SweeperState extends State with SingleTickerProviderStateMixin { _isAppearing = false; if (mounted) { _angleAnimationController.reset(); - _angleAnimationController.forward(); + unawaited(_angleAnimationController.forward()); } } if (mounted) { diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index da4b4eca5..2ca2500e4 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -4,6 +4,7 @@ import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; @@ -161,7 +162,7 @@ class _SectionSelectableLeading extends StatelessWidget { alignment: AlignmentDirectional.topStart, icon: Icon(selected ? AIcons.selected : AIcons.unselected), onPressed: onPressed, - tooltip: selected ? 'Deselect section' : 'Select section', + tooltip: selected ? context.l10n.collectionDeselectSectionTooltip : context.l10n.collectionSelectSectionTooltip, constraints: BoxConstraints( minHeight: leadingDimension, minWidth: leadingDimension, diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index 724a5f6b7..b1a6f1657 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -27,6 +27,11 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { Widget build(BuildContext context) { return ProxyProvider0>( update: (context, _) => _updateLayouts(context), + updateShouldNotify: (previous, current) { + final previousLayouts = previous.sectionLayouts; + final currentLayouts = current.sectionLayouts; + return previousLayouts.length != currentLayouts.length || !previousLayouts.every(currentLayouts.contains); + }, child: child, ); } @@ -138,6 +143,16 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { double getHeaderExtent(BuildContext context, SectionKey sectionKey); Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); + properties.add(IntProperty('columnCount', columnCount)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(DoubleProperty('tileExtent', tileExtent)); + properties.add(DiagnosticsProperty('showHeaders', showHeaders)); + } } class SectionedListLayout { @@ -237,6 +252,12 @@ class SectionLayout { return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; } + @override + bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing; + + @override + int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing); + @override String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}'; } diff --git a/lib/widgets/common/grid/sliver.dart b/lib/widgets/common/grid/sliver.dart index 85ba6b917..1b1ce521f 100644 --- a/lib/widgets/common/grid/sliver.dart +++ b/lib/widgets/common/grid/sliver.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; // Use a `SliverList` instead of multiple `SliverGrid` because having one `SliverGrid` per section does not scale up. // With the multiple `SliverGrid` solution, thumbnails at the beginning of each sections are built even though they are offscreen // because of `RenderSliverMultiBoxAdaptor.addInitialChild` called by `RenderSliverGrid.performLayout` (line 547), as of Flutter v1.17.0. +// cf https://github.com/flutter/flutter/issues/49027 class SectionedListSliver extends StatelessWidget { const SectionedListSliver(); diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index e82b64955..7eabb8a4d 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -36,8 +36,10 @@ class AvesExpansionTile extends StatelessWidget { ), expandable: enabled, initiallyExpanded: initiallyExpanded, + finalPadding: EdgeInsets.symmetric(vertical: 6.0), baseColor: Colors.grey[900], expandedColor: Colors.grey[850], + shadowColor: Theme.of(context).shadowColor, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 69c955659..d7a2e7e2a 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -1,4 +1,4 @@ -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; @@ -11,6 +11,7 @@ import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; typedef FilterCallback = void Function(CollectionFilter filter); typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition); @@ -23,11 +24,11 @@ class AvesFilterChip extends StatefulWidget { final bool showGenericIcon; final Widget background; final Widget details; + final BorderRadius borderRadius; final double padding; final HeroType heroType; final FilterCallback onTap; final OffsetFilterCallback onLongPress; - final BorderRadius borderRadius; static const Color defaultOutlineColor = Colors.white; static const double defaultRadius = 32; @@ -52,7 +53,7 @@ class AvesFilterChip extends StatefulWidget { super(key: key); static Future showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async { - if (AvesApp.mode == AppMode.main) { + if (context.read>().value == AppMode.main) { final actions = [ if (filter is AlbumFilter) ChipAction.goToAlbumPage, if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage, @@ -66,14 +67,13 @@ class AvesFilterChip extends StatefulWidget { final RenderBox overlay = Overlay.of(context).context.findRenderObject(); final touchArea = Size(40, 40); - // TODO TLAD check menu is within safe area, when this lands on stable: https://github.com/flutter/flutter/commit/cfc8ec23b633da1001359e384435e8333c9d3733 final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), items: actions .map((action) => PopupMenuItem( value: action, - child: MenuRow(text: action.getText(), icon: action.getIcon()), + child: MenuRow(text: action.getText(context), icon: action.getIcon()), )) .toList(), ); @@ -100,13 +100,22 @@ class _AvesFilterChipState extends State { double get padding => widget.padding; + FilterCallback get onTap => widget.onTap; + + OffsetFilterCallback get onLongPress => widget.onLongPress; + @override void initState() { super.initState(); - _initColorLoader(); _tapped = false; } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _initColorLoader(); + } + @override void didUpdateWidget(covariant AvesFilterChip oldWidget) { super.didUpdateWidget(oldWidget); @@ -146,7 +155,7 @@ class _AvesFilterChipState extends State { ], Flexible( child: Text( - filter.label, + filter.getLabel(context), softWrap: false, overflow: TextOverflow.fade, maxLines: 1, @@ -203,7 +212,7 @@ class _AvesFilterChipState extends State { child: widget.background, ), Tooltip( - message: filter.tooltip, + message: filter.getTooltip(context), preferBelow: false, child: Material( color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, @@ -213,14 +222,14 @@ class _AvesFilterChipState extends State { child: InkWell( // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, // so we get the long press details from the tap instead - onTapDown: (details) => _tapPosition = details.globalPosition, - onTap: widget.onTap != null + onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, + onTap: onTap != null ? () { - WidgetsBinding.instance.addPostFrameCallback((_) => widget.onTap(filter)); + WidgetsBinding.instance.addPostFrameCallback((_) => onTap(filter)); setState(() => _tapped = true); } : null, - onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null, + onLongPress: onLongPress != null ? () => onLongPress(context, filter, _tapPosition) : null, borderRadius: borderRadius, child: FutureBuilder( future: _colorFuture, diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 953c9ed0c..efba5b527 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -5,114 +5,111 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class VideoIcon extends StatelessWidget { final AvesEntry entry; - final double iconSize; - final bool showDuration; const VideoIcon({ Key key, this.entry, - this.iconSize, - this.showDuration, }) : super(key: key); @override Widget build(BuildContext context) { - return OverlayIcon( + final thumbnailTheme = context.watch(); + final showDuration = thumbnailTheme.showVideoDuration; + Widget child = OverlayIcon( icon: entry.is360 ? AIcons.threesixty : AIcons.play, - size: iconSize, + size: thumbnailTheme.iconSize, text: showDuration ? entry.durationText : null, iconScale: entry.is360 && showDuration ? .9 : 1, ); + if (showDuration) { + child = DefaultTextStyle( + style: TextStyle( + color: Colors.grey[200], + fontSize: thumbnailTheme.fontSize, + ), + child: child, + ); + } + return child; } } class AnimatedImageIcon extends StatelessWidget { - final double iconSize; - - const AnimatedImageIcon({Key key, this.iconSize}) : super(key: key); + const AnimatedImageIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.animated, - size: iconSize, + size: context.select((t) => t.iconSize), iconScale: .8, ); } } class GeotiffIcon extends StatelessWidget { - final double iconSize; - - const GeotiffIcon({Key key, this.iconSize}) : super(key: key); + const GeotiffIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.geo, - size: iconSize, + size: context.select((t) => t.iconSize), ); } } class SphericalImageIcon extends StatelessWidget { - final double iconSize; - - const SphericalImageIcon({Key key, this.iconSize}) : super(key: key); + const SphericalImageIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.threesixty, - size: iconSize, + size: context.select((t) => t.iconSize), ); } } class GpsIcon extends StatelessWidget { - final double iconSize; - - const GpsIcon({Key key, this.iconSize}) : super(key: key); + const GpsIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.location, - size: iconSize, + size: context.select((t) => t.iconSize), ); } } class RawIcon extends StatelessWidget { - final double iconSize; - - const RawIcon({Key key, this.iconSize}) : super(key: key); + const RawIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.raw, - size: iconSize, + size: context.select((t) => t.iconSize), ); } } class MultipageIcon extends StatelessWidget { - final double iconSize; - - const MultipageIcon({Key key, this.iconSize}) : super(key: key); + const MultipageIcon({Key key}) : super(key: key); @override Widget build(BuildContext context) { return OverlayIcon( icon: AIcons.multipage, - size: iconSize, + size: context.select((t) => t.iconSize), iconScale: .8, ); } diff --git a/lib/widgets/collection/empty.dart b/lib/widgets/common/identity/empty.dart similarity index 100% rename from lib/widgets/collection/empty.dart rename to lib/widgets/common/identity/empty.dart diff --git a/lib/widgets/common/identity/scroll_thumb.dart b/lib/widgets/common/identity/scroll_thumb.dart index 268874983..b3faab19e 100644 --- a/lib/widgets/common/identity/scroll_thumb.dart +++ b/lib/widgets/common/identity/scroll_thumb.dart @@ -18,6 +18,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({ margin: EdgeInsets.only(right: .5), padding: EdgeInsets.all(2), child: ClipPath( + clipper: ArrowClipper(), child: Container( width: 20.0, decoration: BoxDecoration( @@ -25,7 +26,6 @@ ScrollThumbBuilder avesScrollThumbBuilder({ borderRadius: BorderRadius.circular(12.0), ), ), - clipper: ArrowClipper(), ), ); return (backgroundColor, thumbAnimation, labelAnimation, height, {labelText}) { diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index a22e2c1ba..1f81097e5 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -264,13 +264,13 @@ class _MagnifierCoreState extends State with TickerProviderStateM ); return MagnifierGestureDetector( - child: child, onDoubleTap: onDoubleTap, onScaleStart: onScaleStart, onScaleUpdate: onScaleUpdate, onScaleEnd: onScaleEnd, hitDetector: this, onTapUp: widget.onTap == null ? null : onTap, + child: child, ); }); } diff --git a/lib/widgets/common/magnifier/core/gesture_detector.dart b/lib/widgets/common/magnifier/core/gesture_detector.dart index b709725ec..84ccf9c33 100644 --- a/lib/widgets/common/magnifier/core/gesture_detector.dart +++ b/lib/widgets/common/magnifier/core/gesture_detector.dart @@ -86,9 +86,9 @@ class _MagnifierGestureDetectorState extends State { ); return RawGestureDetector( - child: widget.child, gestures: gestures, behavior: widget.behavior ?? HitTestBehavior.translucent, + child: widget.child, ); } } diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 61fe5a27c..44fa6f590 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -71,11 +71,11 @@ class Magnifier extends StatelessWidget { )); return MagnifierCore( - child: child, controller: controller, scaleStateCycle: scaleStateCycle, onTap: onTap, applyScale: applyScale, + child: child, ); }, ); diff --git a/lib/widgets/common/providers/tile_extent_controller_provider.dart b/lib/widgets/common/providers/tile_extent_controller_provider.dart new file mode 100644 index 000000000..0b39401c7 --- /dev/null +++ b/lib/widgets/common/providers/tile_extent_controller_provider.dart @@ -0,0 +1,27 @@ +import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class TileExtentControllerProvider extends StatelessWidget { + final TileExtentController controller; + final Widget child; + + const TileExtentControllerProvider({ + @required this.controller, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return LayoutBuilder( + builder: (context, constraints) => ProxyProvider0( + update: (_, __) => controller..setViewportSize(constraints.biggest), + child: child, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 10b479fc1..702f5dbc8 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,9 +2,10 @@ import 'dart:ui' as ui; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; // metadata to identify entry from RenderObject hit test during collection scaling class ScalerMetadata { @@ -14,10 +15,8 @@ class ScalerMetadata { } class GridScaleGestureDetector extends StatefulWidget { - final TileExtentManager tileExtentManager; final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; - final Size viewportSize; final Widget Function(Offset center, double extent, Widget child) gridBuilder; final Widget Function(T item, double extent) scaledBuilder; final Rect Function(BuildContext context, T item) getScaledItemTileRect; @@ -25,10 +24,8 @@ class GridScaleGestureDetector extends StatefulWidget { final Widget child; const GridScaleGestureDetector({ - @required this.tileExtentManager, @required this.scrollableKey, @required this.appBarHeightNotifier, - @required this.viewportSize, this.gridBuilder, @required this.scaledBuilder, @required this.getScaledItemTileRect, @@ -47,10 +44,6 @@ class _GridScaleGestureDetectorState extends State _metadata; - TileExtentManager get tileExtentManager => widget.tileExtentManager; - - Size get viewportSize => widget.viewportSize; - @override Widget build(BuildContext context) { return GestureDetector( @@ -76,8 +69,9 @@ class _GridScaleGestureDetectorState extends State(); + _extentMin = tileExtentController.effectiveExtentMin; + _extentMax = tileExtentController.effectiveExtentMax; final halfExtent = _startExtent / 2; final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent)); @@ -105,12 +99,10 @@ class _GridScaleGestureDetectorState extends State(); + final oldExtent = tileExtentController.extentNotifier.value; // sanitize and update grid layout if necessary - final newExtent = tileExtentManager.applyTileExtent( - viewportSize: widget.viewportSize, - userPreferredExtent: _scaledExtentNotifier.value, - ); + final newExtent = tileExtentController.setUserPreferredExtent(_scaledExtentNotifier.value); _scaledExtentNotifier = null; if (newExtent == oldExtent) { _applyingScale = false; @@ -195,59 +187,61 @@ class _ScaleOverlayState extends State { @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: IgnorePointer( - child: AnimatedContainer( - decoration: _init - ? BoxDecoration( - gradient: RadialGradient( - center: FractionalOffset.fromOffsetAndSize(center, MediaQuery.of(context).size), - radius: 1, - colors: [ - Colors.black, - Colors.black54, - ], - ), - ) - : BoxDecoration( - // provide dummy gradient to lerp to the other one during animation - gradient: RadialGradient( - colors: [ - Colors.transparent, - Colors.transparent, - ], - ), - ), - duration: Durations.collectionScalingBackgroundAnimation, - child: ValueListenableBuilder( - valueListenable: widget.scaledExtentNotifier, - builder: (context, extent, child) { - // keep scaled thumbnail within the screen - final xMin = MediaQuery.of(context).padding.left; - final xMax = xMin + gridWidth; - var dx = .0; - if (center.dx - extent / 2 < xMin) { - dx = xMin - (center.dx - extent / 2); - } else if (center.dx + extent / 2 > xMax) { - dx = xMax - (center.dx + extent / 2); - } - final clampedCenter = center.translate(dx, 0); - - var child = widget.builder(extent); - child = Stack( - children: [ - Positioned( - left: clampedCenter.dx - extent / 2, - top: clampedCenter.dy - extent / 2, - child: DefaultTextStyle( - style: TextStyle(), - child: child, + child: Builder( + builder: (context) => IgnorePointer( + child: AnimatedContainer( + decoration: _init + ? BoxDecoration( + gradient: RadialGradient( + center: FractionalOffset.fromOffsetAndSize(center, context.select((mq) => mq.size)), + radius: 1, + colors: [ + Colors.black, + Colors.black54, + ], + ), + ) + : BoxDecoration( + // provide dummy gradient to lerp to the other one during animation + gradient: RadialGradient( + colors: [ + Colors.transparent, + Colors.transparent, + ], ), ), - ], - ); - child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; - return child; - }, + duration: Durations.collectionScalingBackgroundAnimation, + child: ValueListenableBuilder( + valueListenable: widget.scaledExtentNotifier, + builder: (context, extent, child) { + // keep scaled thumbnail within the screen + final xMin = context.select((mq) => mq.padding.left); + final xMax = xMin + gridWidth; + var dx = .0; + if (center.dx - extent / 2 < xMin) { + dx = xMin - (center.dx - extent / 2); + } else if (center.dx + extent / 2 > xMax) { + dx = xMax - (center.dx + extent / 2); + } + final clampedCenter = center.translate(dx, 0); + + var child = widget.builder(extent); + child = Stack( + children: [ + Positioned( + left: clampedCenter.dx - extent / 2, + top: clampedCenter.dy - extent / 2, + child: DefaultTextStyle( + style: TextStyle(), + child: child, + ), + ), + ], + ); + child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; + return child; + }, + ), ), ), ), diff --git a/lib/widgets/common/tile_extent_controller.dart b/lib/widgets/common/tile_extent_controller.dart new file mode 100644 index 000000000..fa782e058 --- /dev/null +++ b/lib/widgets/common/tile_extent_controller.dart @@ -0,0 +1,81 @@ +import 'dart:math'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:flutter/widgets.dart'; + +class TileExtentController { + final String settingsRouteKey; + final int columnCountMin, columnCountDefault; + final double spacing, extentMin, extentMax; + final ValueNotifier extentNotifier = ValueNotifier(0); + + Size _viewportSize; + + Size get viewportSize => _viewportSize; + + TileExtentController({ + @required this.settingsRouteKey, + this.columnCountMin = 2, + @required this.columnCountDefault, + @required this.extentMin, + this.extentMax = 300, + @required this.spacing, + }); + + void setViewportSize(Size viewportSize) { + // sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size) + final viewportSizeMin = Size.square(extentMin * columnCountMin); + // dimensions are rounded to prevent updates on minor changes + // e.g. available space on S10e is `Size(360.0, 721.0)` when status bar is visible, `Size(360.0, 721.3)` when it is not + final newViewportSize = Size(max(viewportSize.width, viewportSizeMin.width).roundToDouble(), max(viewportSize.height, viewportSizeMin.height).roundToDouble()); + if (_viewportSize != newViewportSize) { + _viewportSize = newViewportSize; + _update(); + } + } + + double setUserPreferredExtent(double userPreferredExtent) => _update(userPreferredExtent: userPreferredExtent); + + double _update({double userPreferredExtent = 0}) { + final oldUserPreferredExtent = settings.getTileExtent(settingsRouteKey); + final currentExtent = extentNotifier.value; + final targetExtent = userPreferredExtent > 0 + ? userPreferredExtent + : oldUserPreferredExtent > 0 + ? oldUserPreferredExtent + : currentExtent; + + final columnCount = getEffectiveColumnCountForExtent(targetExtent); + final newExtent = _extentForColumnCount(columnCount); + + if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) { + settings.setTileExtent(settingsRouteKey, newExtent); + } + if (extentNotifier.value != newExtent) { + extentNotifier.value = newExtent; + } + return newExtent; + } + + double _extentMax() => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin); + + double _columnCountForExtent(double extent) => (viewportSize.width + spacing) / (extent + spacing); + + double _extentForColumnCount(int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount; + + int _effectiveColumnCountMin() => _columnCountForExtent(_extentMax()).ceil(); + + int _effectiveColumnCountMax() => _columnCountForExtent(extentMin).floor(); + + double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax()); + + double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin()); + + int getEffectiveColumnCountForExtent(double extent) { + if (extent > 0) { + final columnCount = _columnCountForExtent(extent); + return columnCount.clamp(_effectiveColumnCountMin(), _effectiveColumnCountMax()).round(); + } + return columnCountDefault; + } +} diff --git a/lib/widgets/common/tile_extent_manager.dart b/lib/widgets/common/tile_extent_manager.dart deleted file mode 100644 index a5e3bc1ac..000000000 --- a/lib/widgets/common/tile_extent_manager.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/settings/settings.dart'; -import 'package:flutter/widgets.dart'; - -class TileExtentManager { - final String settingsRouteKey; - final int columnCountMin, columnCountDefault; - final double spacing, extentMin, extentMax; - final ValueNotifier extentNotifier; - - const TileExtentManager({ - @required this.settingsRouteKey, - @required this.extentNotifier, - this.columnCountMin = 2, - @required this.columnCountDefault, - @required this.extentMin, - this.extentMax = 300, - @required this.spacing, - }); - - double applyTileExtent({ - @required Size viewportSize, - double userPreferredExtent = 0, - }) { - // sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size) - final viewportSizeMin = Size.square(extentMin * columnCountMin); - viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height)); - - final oldUserPreferredExtent = settings.getTileExtent(settingsRouteKey); - final currentExtent = extentNotifier.value; - final targetExtent = userPreferredExtent > 0 - ? userPreferredExtent - : oldUserPreferredExtent > 0 - ? oldUserPreferredExtent - : currentExtent; - - final columnCount = getEffectiveColumnCountForExtent(viewportSize, targetExtent); - final newExtent = _extentForColumnCount(viewportSize, columnCount); - - if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) { - settings.setTileExtent(settingsRouteKey, newExtent); - } - if (extentNotifier.value != newExtent) { - extentNotifier.value = newExtent; - } - return newExtent; - } - - double _extentMax(Size viewportSize) => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin); - - double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing); - - double _extentForColumnCount(Size viewportSize, int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount; - - int _effectiveColumnCountMin(Size viewportSize) => _columnCountForExtent(viewportSize, _extentMax(viewportSize)).ceil(); - - int _effectiveColumnCountMax(Size viewportSize) => _columnCountForExtent(viewportSize, extentMin).floor(); - - double getEffectiveExtentMin(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMax(viewportSize)); - - double getEffectiveExtentMax(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMin(viewportSize)); - - int getEffectiveColumnCountForExtent(Size viewportSize, double extent) { - if (extent > 0) { - final columnCount = _columnCountForExtent(viewportSize, extent); - return columnCount.clamp(_effectiveColumnCountMin(viewportSize), _effectiveColumnCountMax(viewportSize)).round(); - } - return columnCountDefault; - } -} diff --git a/lib/widgets/debug/cache.dart b/lib/widgets/debug/cache.dart index 09b8e9aa3..ed37531ac 100644 --- a/lib/widgets/debug/cache.dart +++ b/lib/widgets/debug/cache.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:flutter/material.dart'; @@ -60,7 +60,7 @@ class _DebugCacheSectionState extends State with AutomaticKee ), SizedBox(width: 8), ElevatedButton( - onPressed: ImageFileService.clearSizedThumbnailDiskCache, + onPressed: imageFileService.clearSizedThumbnailDiskCache, child: Text('Clear'), ), ], diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index ea881a82e..9dd19894c 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -1,7 +1,8 @@ +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourite_repo.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:flutter/material.dart'; @@ -17,7 +18,8 @@ class _DebugAppDatabaseSectionState extends State with Future> _dbDateLoader; Future> _dbMetadataLoader; Future> _dbAddressLoader; - Future> _dbFavouritesLoader; + Future> _dbFavouritesLoader; + Future> _dbCoversLoader; @override void initState() { @@ -141,7 +143,7 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), - FutureBuilder( + FutureBuilder( future: _dbFavouritesLoader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); @@ -162,6 +164,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + future: _dbCoversLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + + return Row( + children: [ + Expanded( + child: Text('cover rows: ${snapshot.data.length} (${covers.count} in memory)'), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: () => covers.clear().then((_) => _startDbReport()), + child: Text('Clear'), + ), + ], + ); + }, + ), ], ), ), @@ -176,6 +199,7 @@ class _DebugAppDatabaseSectionState extends State with _dbMetadataLoader = metadataDb.loadMetadataEntries(); _dbAddressLoader = metadataDb.loadAddresses(); _dbFavouritesLoader = metadataDb.loadFavourites(); + _dbCoversLoader = metadataDb.loadCovers(); setState(() {}); } diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 463aff4a0..9fb6b0f06 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -1,5 +1,4 @@ import 'package:aves/services/service_policy.dart'; -import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:flutter/material.dart'; class DebugTaskQueueOverlay extends StatelessWidget { @@ -13,9 +12,6 @@ class DebugTaskQueueOverlay extends StatelessWidget { child: SafeArea( child: Container( color: Colors.indigo[900].withAlpha(0xCC), - margin: EdgeInsets.only( - bottom: MediaQuery.of(context).effectiveBottomPadding, - ), padding: EdgeInsets.all(8), child: StreamBuilder( stream: servicePolicy.queueStream, diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index 9a1759feb..4ad248219 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -38,8 +38,11 @@ class DebugSettingsSection extends StatelessWidget { 'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}', 'infoMapZoom': '${settings.infoMapZoom}', 'pinnedFilters': toMultiline(settings.pinnedFilters), + 'hiddenFilters': toMultiline(settings.hiddenFilters), 'searchHistory': toMultiline(settings.searchHistory), 'lastVersionCheckDate': '${settings.lastVersionCheckDate}', + 'locale': '${settings.locale}', + 'systemLocale': '${WidgetsBinding.instance.window.locale}', }), ), ], diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index b3a1ac803..fb29874df 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -39,7 +39,7 @@ class _DebugStorageSectionState extends State with Automati Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: InfoRowGroup({ - 'description': '${v.description}', + 'description': '${v.getDescription(context)}', 'isPrimary': '${v.isPrimary}', 'isRemovable': '${v.isRemovable}', 'state': '${v.state}', diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index b321ec0c1..a12edb60c 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -1,12 +1,14 @@ -import 'package:aves/model/filters/filters.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'aves_dialog.dart'; class AddShortcutDialog extends StatefulWidget { - final Set filters; + final String defaultName; - const AddShortcutDialog(this.filters); + const AddShortcutDialog({ + @required this.defaultName, + }); @override _AddShortcutDialogState createState() => _AddShortcutDialogState(); @@ -19,12 +21,7 @@ class _AddShortcutDialogState extends State { @override void initState() { super.initState(); - final filters = List.from(widget.filters)..sort(); - if (filters.isEmpty) { - _nameController.text = 'Collection'; - } else { - _nameController.text = filters.first.label; - } + _nameController.text = widget.defaultName; _validate(); } @@ -41,7 +38,7 @@ class _AddShortcutDialogState extends State { content: TextField( controller: _nameController, decoration: InputDecoration( - labelText: 'Shortcut label', + labelText: context.l10n.addShortcutDialogLabel, ), autofocus: true, maxLength: 25, @@ -51,14 +48,14 @@ class _AddShortcutDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, - child: Text('Add'.toUpperCase()), + child: Text(context.l10n.addShortcutButtonLabel), ); }, ) diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index 8427c6af3..1542726bd 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -92,12 +93,12 @@ void showNoMatchingAppDialog(BuildContext context) { builder: (context) { return AvesDialog( context: context, - title: 'No Matching App', - content: Text('There are no apps that can handle this.'), + title: context.l10n.noMatchingAppDialogTitle, + content: Text(context.l10n.noMatchingAppDialogMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('OK'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).okButtonLabel), ), ], ); diff --git a/lib/widgets/dialogs/aves_selection_dialog.dart b/lib/widgets/dialogs/aves_selection_dialog.dart index c4e5c7e4b..d2c8b9185 100644 --- a/lib/widgets/dialogs/aves_selection_dialog.dart +++ b/lib/widgets/dialogs/aves_selection_dialog.dart @@ -23,7 +23,7 @@ class AvesSelectionDialog extends StatefulWidget { _AvesSelectionDialogState createState() => _AvesSelectionDialogState(); } -class _AvesSelectionDialogState extends State { +class _AvesSelectionDialogState extends State> { T _selectedValue; @override @@ -41,13 +41,14 @@ class _AvesSelectionDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), ], ); } Widget _buildRadioListTile(T value, String title) { + final subtitle = widget.optionSubtitleBuilder?.call(value); return ReselectableRadioListTile( key: Key(value.toString()), value: value, @@ -64,9 +65,9 @@ class _AvesSelectionDialogState extends State { overflow: TextOverflow.fade, maxLines: 1, ), - subtitle: widget.optionSubtitleBuilder != null + subtitle: subtitle != null ? Text( - widget.optionSubtitleBuilder(value), + subtitle, softWrap: false, overflow: TextOverflow.fade, maxLines: 1, diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/cover_selection_dialog.dart new file mode 100644 index 000000000..e939810f9 --- /dev/null +++ b/lib/widgets/dialogs/cover_selection_dialog.dart @@ -0,0 +1,134 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; +import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class CoverSelectionDialog extends StatefulWidget { + final CollectionFilter filter; + final AvesEntry customEntry; + + const CoverSelectionDialog({ + @required this.filter, + @required this.customEntry, + }); + + @override + _CoverSelectionDialogState createState() => _CoverSelectionDialogState(); +} + +class _CoverSelectionDialogState extends State { + bool _isCustom; + AvesEntry _customEntry, _recentEntry; + + CollectionFilter get filter => widget.filter; + + @override + void initState() { + super.initState(); + _recentEntry = context.read().recentEntry(filter); + _customEntry = widget.customEntry; + _isCustom = _customEntry != null; + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Builder( + builder: (context) { + final l10n = context.l10n; + final shortestSide = context.select((mq) => mq.size.shortestSide); + final extent = (shortestSide / 3.0).clamp(60.0, 160.0); + return AvesDialog( + context: context, + title: l10n.setCoverDialogTitle, + scrollableContent: [ + ...[false, true].map( + (isCustom) { + final title = Text( + isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogLatest, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + return RadioListTile( + value: isCustom, + groupValue: _isCustom, + onChanged: (v) { + if (v && _customEntry == null) { + _pickEntry(); + return; + } + _isCustom = v; + setState(() {}); + }, + title: isCustom + ? Row(children: [ + title, + Spacer(), + IconButton( + onPressed: _isCustom ? _pickEntry : null, + tooltip: 'Change', + icon: Icon(AIcons.setCover), + ), + ]) + : title, + ); + }, + ), + Container( + alignment: Alignment.center, + padding: EdgeInsets.only(bottom: 16), + child: DecoratedFilterChip( + filter: filter, + extent: extent, + coverEntry: _isCustom ? _customEntry : _recentEntry, + onTap: (filter) => _pickEntry(), + ), + ), + ], + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.pop(context, Tuple2(_isCustom, _customEntry)), + child: Text(l10n.applyButtonLabel), + ), + ], + ); + }, + ), + ); + } + + Future _pickEntry() async { + final entry = await Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: ItemPickDialog.routeName), + builder: (context) => ItemPickDialog( + CollectionLens( + source: context.read(), + filters: [filter], + ), + ), + fullscreenDialog: true, + ), + ); + if (entry != null) { + _customEntry = entry; + _isCustom = true; + setState(() {}); + } + } +} diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart index 26c41dad1..afc19ca6f 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; @@ -50,17 +51,17 @@ class _CreateAlbumDialogState extends State { volumeTiles.addAll([ Padding( padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20), - child: Text('Storage:'), + child: Text(context.l10n.newAlbumDialogStorageLabel), ), - ...primaryVolumes.map(_buildVolumeTile), - ...otherVolumes.map(_buildVolumeTile), + ...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)), + ...otherVolumes.map((volume) => _buildVolumeTile(context, volume)), SizedBox(height: 8), ]); } return AvesDialog( context: context, - title: 'New Album', + title: context.l10n.newAlbumDialogTitle, scrollController: _scrollController, scrollableContent: [ ...volumeTiles, @@ -73,8 +74,8 @@ class _CreateAlbumDialogState extends State { controller: _nameController, focusNode: _nameFieldFocusNode, decoration: InputDecoration( - labelText: 'Album name', - helperText: exists ? 'Directory already exists' : '', + labelText: context.l10n.newAlbumDialogNameLabel, + helperText: exists ? context.l10n.newAlbumDialogNameLabelAlreadyExistsHelper : '', ), autofocus: _allVolumes.length == 1, onChanged: (_) => _validate(), @@ -86,14 +87,14 @@ class _CreateAlbumDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, - child: Text('Create'.toUpperCase()), + child: Text(context.l10n.createAlbumButtonLabel), ); }, ), @@ -101,7 +102,7 @@ class _CreateAlbumDialogState extends State { ); } - Widget _buildVolumeTile(StorageVolume volume) => RadioListTile( + Widget _buildVolumeTile(BuildContext context, StorageVolume volume) => RadioListTile( value: volume, groupValue: _selectedVolume, onChanged: (volume) { @@ -110,7 +111,7 @@ class _CreateAlbumDialogState extends State { setState(() {}); }, title: Text( - volume.description, + volume.getDescription(context), softWrap: false, overflow: TextOverflow.fade, maxLines: 1, diff --git a/lib/widgets/dialogs/item_pick_dialog.dart b/lib/widgets/dialogs/item_pick_dialog.dart new file mode 100644 index 000000000..013f14fe4 --- /dev/null +++ b/lib/widgets/dialogs/item_pick_dialog.dart @@ -0,0 +1,51 @@ +import 'package:aves/app_mode.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/widgets/collection/collection_grid.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ItemPickDialog extends StatefulWidget { + static const routeName = '/item_pick'; + + final CollectionLens collection; + + const ItemPickDialog(this.collection); + + @override + _ItemPickDialogState createState() => _ItemPickDialogState(); +} + +class _ItemPickDialogState extends State { + CollectionLens get collection => widget.collection; + + @override + void dispose() { + collection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableProvider>.value( + value: ValueNotifier(AppMode.pickInternal), + child: MediaQueryDataProvider( + child: Scaffold( + body: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: collection, + child: CollectionGrid( + settingsRouteKey: CollectionPage.routeName, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/rename_album_dialog.dart b/lib/widgets/dialogs/rename_album_dialog.dart index 7997464fb..6d16f74fb 100644 --- a/lib/widgets/dialogs/rename_album_dialog.dart +++ b/lib/widgets/dialogs/rename_album_dialog.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; @@ -46,8 +47,8 @@ class _RenameAlbumDialogState extends State { return TextField( controller: _nameController, decoration: InputDecoration( - labelText: 'New name', - helperText: exists ? 'Directory already exists' : '', + labelText: context.l10n.renameAlbumDialogLabel, + helperText: exists ? context.l10n.renameAlbumDialogLabelAlreadyExistsHelper : '', ), autofocus: true, onChanged: (_) => _validate(), @@ -57,14 +58,14 @@ class _RenameAlbumDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, - child: Text('Apply'.toUpperCase()), + child: Text(context.l10n.applyButtonLabel), ); }, ) diff --git a/lib/widgets/dialogs/rename_entry_dialog.dart b/lib/widgets/dialogs/rename_entry_dialog.dart index d73410ba9..c72028759 100644 --- a/lib/widgets/dialogs/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/rename_entry_dialog.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:aves/model/entry.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; @@ -41,7 +42,7 @@ class _RenameEntryDialogState extends State { content: TextField( controller: _nameController, decoration: InputDecoration( - labelText: 'New name', + labelText: context.l10n.renameEntryDialogLabel, suffixText: entry.extension, ), autofocus: true, @@ -51,14 +52,14 @@ class _RenameEntryDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), ValueListenableBuilder( valueListenable: _isValidNotifier, builder: (context, isValid, child) { return TextButton( onPressed: isValid ? () => _submit(context) : null, - child: Text('Apply'.toUpperCase()), + child: Text(context.l10n.applyButtonLabel), ); }, ) diff --git a/lib/widgets/drawer/album_tile.dart b/lib/widgets/drawer/album_tile.dart index ca8b17fd5..6bb8b2199 100644 --- a/lib/widgets/drawer/album_tile.dart +++ b/lib/widgets/drawer/album_tile.dart @@ -15,7 +15,7 @@ class AlbumTile extends StatelessWidget { @override Widget build(BuildContext context) { final source = context.read(); - final uniqueName = source.getUniqueAlbumName(album); + final uniqueName = source.getUniqueAlbumName(context, album); return CollectionNavTile( leading: IconUtils.getAlbumIcon( context: context, diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index c3322e4dd..690702f7d 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:aves/model/availability.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/source/album.dart'; @@ -8,10 +7,12 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/about/news_badge.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; @@ -64,26 +65,25 @@ class _AppDrawerState extends State { ]; return Drawer( - child: Selector( - selector: (c, mq) => mq.effectiveBottomPadding, - builder: (c, mqPaddingBottom, child) { - final iconTheme = IconTheme.of(context); - return SingleChildScrollView( - padding: EdgeInsets.only(bottom: mqPaddingBottom), - child: Theme( - data: Theme.of(context).copyWith( - // color used by `ExpansionTile` for leading icon - unselectedWidgetColor: Colors.white, - ), + child: ListTileTheme.merge( + selectedColor: Theme.of(context).accentColor, + child: Selector( + selector: (c, mq) => mq.effectiveBottomPadding, + builder: (c, mqPaddingBottom, child) { + final iconTheme = IconTheme.of(context); + return SingleChildScrollView( + padding: EdgeInsets.only(bottom: mqPaddingBottom), child: IconTheme( - data: iconTheme.copyWith(size: iconTheme.size * MediaQuery.textScaleFactorOf(context)), + data: iconTheme.copyWith( + size: iconTheme.size * MediaQuery.textScaleFactorOf(context), + ), child: Column( children: drawerItems, ), ), - ), - ); - }, + ); + }, + ), ), ); } @@ -105,7 +105,7 @@ class _AppDrawerState extends State { children: [ AvesLogo(size: 64), Text( - 'Aves', + context.l10n.appName, style: TextStyle( fontSize: 44, fontWeight: FontWeight.w300, @@ -146,25 +146,25 @@ class _AppDrawerState extends State { Widget get allCollectionTile => CollectionNavTile( leading: Icon(AIcons.allCollection), - title: 'All collection', + title: context.l10n.drawerCollectionAll, filter: null, ); Widget get videoTile => CollectionNavTile( leading: Icon(AIcons.video), - title: 'Videos', + title: context.l10n.drawerCollectionVideos, filter: MimeFilter(MimeTypes.anyVideo), ); Widget get favouriteTile => CollectionNavTile( leading: Icon(AIcons.favourite), - title: 'Favourites', + title: context.l10n.drawerCollectionFavourites, filter: FavouriteFilter(), ); Widget get albumListTile => NavTile( icon: AIcons.album, - title: 'Albums', + title: context.l10n.albumPageTitle, trailing: StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.rawAlbums.length}'), @@ -175,7 +175,7 @@ class _AppDrawerState extends State { Widget get countryListTile => NavTile( icon: AIcons.location, - title: 'Countries', + title: context.l10n.countryPageTitle, trailing: StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.sortedCountries.length}'), @@ -186,7 +186,7 @@ class _AppDrawerState extends State { Widget get tagListTile => NavTile( icon: AIcons.tag, - title: 'Tags', + title: context.l10n.tagPageTitle, trailing: StreamBuilder( stream: source.eventBus.on(), builder: (context, _) => Text('${source.sortedTags.length}'), @@ -197,7 +197,7 @@ class _AppDrawerState extends State { Widget get settingsTile => NavTile( icon: AIcons.settings, - title: 'Settings', + title: context.l10n.settingsPageTitle, topLevel: false, routeName: SettingsPage.routeName, pageBuilder: (_) => SettingsPage(), @@ -209,7 +209,7 @@ class _AppDrawerState extends State { final newVersion = snapshot.data == true; return NavTile( icon: AIcons.info, - title: 'About', + title: context.l10n.aboutPageTitle, trailing: newVersion ? AboutNewsBadge() : null, topLevel: false, routeName: AboutPage.routeName, diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index e21fc62a3..f53813f83 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -7,10 +7,11 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; @@ -57,21 +58,21 @@ class _AlbumPickPageState extends State { return StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) => FilterGridPage( + settingsRouteKey: AlbumListPage.routeName, appBar: appBar, - filterSections: AlbumListPage.getAlbumEntries(source), + appBarHeight: AlbumPickAppBar.preferredHeight, + filterSections: AlbumListPage.getAlbumEntries(context, source), showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, + queryNotifier: _queryNotifier, applyQuery: (filters, query) { if (query == null || query.isEmpty) return filters; query = query.toUpperCase(); return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList(); }, - queryNotifier: _queryNotifier, emptyBuilder: () => EmptyContent( icon: AIcons.album, - text: 'No albums', + text: context.l10n.albumEmpty, ), - settingsRouteKey: AlbumListPage.routeName, - appBarHeight: AlbumPickAppBar.preferredHeight, onTap: (filter) => Navigator.pop(context, (filter as AlbumFilter)?.album), ), ); @@ -100,11 +101,11 @@ class AlbumPickAppBar extends StatelessWidget { String title() { switch (moveType) { case MoveType.copy: - return 'Copy to Album'; + return context.l10n.albumPickPageTitleCopy; case MoveType.export: - return 'Export to Album'; + return context.l10n.albumPickPageTitleExport; case MoveType.move: - return 'Move to Album'; + return context.l10n.albumPickPageTitleMove; default: return null; } @@ -131,22 +132,26 @@ class AlbumPickAppBar extends StatelessWidget { Navigator.pop(context, newAlbum); } }, - tooltip: 'Create album', + tooltip: context.l10n.createAlbumTooltip, ), PopupMenuButton( itemBuilder: (context) { return [ PopupMenuItem( value: ChipSetAction.sort, - child: MenuRow(text: 'Sort…', icon: AIcons.sort), + child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort), ), PopupMenuItem( value: ChipSetAction.group, - child: MenuRow(text: 'Group…', icon: AIcons.group), + child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), ), ]; }, onSelected: (action) { + // remove focus, if any, to prevent the keyboard from showing up + // after the user is done with the popup menu + FocusManager.instance.primaryFocus?.unfocus(); + // wait for the popup menu to hide before proceeding with the action Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action)); }, diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 10350bea2..5b4df6a3e 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -7,7 +7,8 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; @@ -32,21 +33,22 @@ class AlbumListPage extends StatelessWidget { stream: source.eventBus.on(), builder: (context, snapshot) => FilterNavigationPage( source: source, - title: 'Albums', + title: context.l10n.albumPageTitle, groupable: true, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, chipSetActionDelegate: AlbumChipSetActionDelegate(), chipActionDelegate: AlbumChipActionDelegate(), chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, + ChipAction.setCover, ChipAction.rename, ChipAction.delete, ChipAction.hide, ], - filterSections: getAlbumEntries(source), + filterSections: getAlbumEntries(context, source), emptyBuilder: () => EmptyContent( icon: AIcons.album, - text: 'No albums', + text: context.l10n.albumEmpty, ), ), ), @@ -57,14 +59,14 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static Map>> getAlbumEntries(CollectionSource source) { - final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(album))).toSet(); + static Map>> getAlbumEntries(BuildContext context, CollectionSource source) { + final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(context, album))).toSet(); final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); - return _group(sorted); + return _group(context, sorted); } - static Map>> _group(Iterable> sortedMapEntries) { + static Map>> _group(BuildContext context, Iterable> sortedMapEntries) { final pinned = settings.pinnedFilters.whereType(); final byPin = groupBy, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final pinnedMapEntries = (byPin[true] ?? []); @@ -73,25 +75,28 @@ class AlbumListPage extends StatelessWidget { var sections = >>{}; switch (settings.albumGroupFactor) { case AlbumChipGroupFactor.importance: + final specialKey = AlbumImportanceSectionKey.special(context); + final appsKey = AlbumImportanceSectionKey.apps(context); + final regularKey = AlbumImportanceSectionKey.regular(context); sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { switch (androidFileUtils.getAlbumType(kv.filter.album)) { case AlbumType.regular: - return AlbumImportanceSectionKey.regular; + return regularKey; case AlbumType.app: - return AlbumImportanceSectionKey.apps; + return appsKey; default: - return AlbumImportanceSectionKey.special; + return specialKey; } }); sections = { - AlbumImportanceSectionKey.special: sections[AlbumImportanceSectionKey.special], - AlbumImportanceSectionKey.apps: sections[AlbumImportanceSectionKey.apps], - AlbumImportanceSectionKey.regular: sections[AlbumImportanceSectionKey.regular], + specialKey: sections[specialKey], + appsKey: sections[appsKey], + regularKey: sections[regularKey], }..removeWhere((key, value) => value == null); break; case AlbumChipGroupFactor.volume: sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { - return StorageVolumeSectionKey(androidFileUtils.getStorageVolume(kv.filter.album)); + return StorageVolumeSectionKey(context, androidFileUtils.getStorageVolume(kv.filter.album)); }); break; case AlbumChipGroupFactor.none: @@ -106,7 +111,7 @@ class AlbumListPage extends StatelessWidget { if (pinnedMapEntries.isNotEmpty) { sections = Map.fromEntries([ - MapEntry(AlbumImportanceSectionKey.pinned, pinnedMapEntries), + MapEntry(AlbumImportanceSectionKey.pinned(context), pinnedMapEntries), ...sections.entries, ]); } diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 00f4e67d7..7a95a56b3 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -1,24 +1,30 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/covers.dart'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/image_op_events.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; class ChipActionDelegate { void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { @@ -32,6 +38,9 @@ class ChipActionDelegate { case ChipAction.hide: _hide(context, filter); break; + case ChipAction.setCover: + _showCoverSelectionDialog(context, filter); + break; case ChipAction.goToAlbumPage: _goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage()); break; @@ -52,15 +61,15 @@ class ChipActionDelegate { builder: (context) { return AvesDialog( context: context, - content: Text('Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?'), + content: Text(context.l10n.hideFilterConfirmationDialogMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('Hide'.toUpperCase()), + child: Text(context.l10n.hideButtonLabel), ), ], ); @@ -72,6 +81,22 @@ class ChipActionDelegate { source.changeFilterVisibility(filter, false); } + void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async { + final contentId = covers.coverContentId(filter); + final customEntry = context.read().visibleEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); + final coverSelection = await showDialog>( + context: context, + builder: (context) => CoverSelectionDialog( + filter: filter, + customEntry: customEntry, + ), + ); + if (coverSelection == null) return; + + final isCustom = coverSelection.item1; + await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null); + } + void _goTo( BuildContext context, CollectionFilter filter, @@ -116,15 +141,15 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per builder: (context) { return AvesDialog( context: context, - content: Text('Are you sure you want to delete this album and its ${Intl.plural(count, one: 'item', other: '$count items')}?'), + content: Text(context.l10n.deleteAlbumConfirmationDialogMessage(count)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('Delete'.toUpperCase()), + child: Text(context.l10n.deleteButtonLabel), ), ], ); @@ -138,24 +163,35 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per source.pauseMonitoring(); showOpReport( context: context, - opStream: ImageFileService.delete(selection), + opStream: imageFileService.delete(selection), itemCount: selectionCount, - onDone: (processed) { + onDone: (processed) async { final deletedUris = processed.where((event) => event.success).map((event) => event.uri).toSet(); + await source.removeEntries(deletedUris); + source.resumeMonitoring(); + final deletedCount = deletedUris.length; if (deletedCount < selectionCount) { final count = selectionCount - deletedCount; - showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}'); + showFeedback(context, context.l10n.collectionDeleteFailureFeedback(count)); } - source.removeEntries(deletedUris); - source.resumeMonitoring(); }, ); } Future _showRenameDialog(BuildContext context, AlbumFilter filter) async { - final source = context.read(); final album = filter.album; + + // check whether renaming is possible given OS restrictions, + // before asking to input a new name + final restrictedDirs = await AndroidFileService.getRestrictedDirectories(); + final dir = VolumeRelativeDirectory.fromPath(album); + if (restrictedDirs.contains(dir)) { + await showRestrictedDirectoryDialog(context, dir); + return; + } + + final source = context.read(); final newName = await showDialog( context: context, builder: (context) => RenameAlbumDialog(album), @@ -169,38 +205,27 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final todoCount = todoEntries.length; - // while the move is ongoing, source monitoring may remove entries from itself and the favourites repo - // so we save favourites beforehand, and will mark the moved entries as such after the move - final favouriteEntries = todoEntries.where((entry) => entry.isFavourite).toSet(); source.pauseMonitoring(); showOpReport( context: context, - opStream: ImageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), + opStream: imageFileService.move(todoEntries, copy: false, destinationAlbum: destinationAlbum), itemCount: todoCount, onDone: (processed) async { final movedOps = processed.where((e) => e.success).toSet(); + await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps); + source.resumeMonitoring(); + final movedCount = movedOps.length; if (movedCount < todoCount) { final count = todoCount - movedCount; - showFeedback(context, 'Failed to move ${Intl.plural(count, one: '$count item', other: '$count items')}'); + showFeedbackWithMessenger(messenger, l10n.collectionMoveFailureFeedback(count)); } else { - showFeedback(context, 'Done!'); + showFeedbackWithMessenger(messenger, l10n.genericSuccessFeedback); } - final pinned = settings.pinnedFilters.contains(filter); - await source.updateAfterMove( - todoEntries: todoEntries, - favouriteEntries: favouriteEntries, - copy: false, - destinationAlbum: destinationAlbum, - movedOps: movedOps, - ); - // repin new album after obsolete album got removed and unpinned - if (pinned) { - final newFilter = AlbumFilter(destinationAlbum, source.getUniqueAlbumName(destinationAlbum)); - settings.pinnedFilters = settings.pinnedFilters..add(newFilter); - } - source.resumeMonitoring(); }, ); } diff --git a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart index 1ea75bcab..299a47744 100644 --- a/lib/widgets/filter_grids/common/chip_set_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_set_action_delegate.dart @@ -2,6 +2,7 @@ import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/stats/stats.dart'; import 'package:flutter/material.dart'; @@ -13,14 +14,10 @@ abstract class ChipSetActionDelegate { set sortFactor(ChipSortFactor factor); void onActionSelected(BuildContext context, ChipSetAction action) { - final source = context.read(); switch (action) { case ChipSetAction.sort: _showSortDialog(context); break; - case ChipSetAction.refresh: - source.refresh(); - break; case ChipSetAction.stats: _goToStats(context); break; @@ -35,11 +32,11 @@ abstract class ChipSetActionDelegate { builder: (context) => AvesSelectionDialog( initialValue: sortFactor, options: { - ChipSortFactor.date: 'By date', - ChipSortFactor.name: 'By name', - ChipSortFactor.count: 'By item count', + ChipSortFactor.date: context.l10n.chipSortDate, + ChipSortFactor.name: context.l10n.chipSortName, + ChipSortFactor.count: context.l10n.chipSortCount, }, - title: 'Sort', + title: context.l10n.chipSortTitle, ), ); if (factor != null) { @@ -86,11 +83,11 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { builder: (context) => AvesSelectionDialog( initialValue: settings.albumGroupFactor, options: { - AlbumChipGroupFactor.importance: 'By tier', - AlbumChipGroupFactor.volume: 'By storage volume', - AlbumChipGroupFactor.none: 'Do not group', + AlbumChipGroupFactor.importance: context.l10n.albumGroupTier, + AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume, + AlbumChipGroupFactor.none: context.l10n.albumGroupNone, }, - title: 'Group', + title: context.l10n.albumGroupTitle, ), ); if (factor != null) { diff --git a/lib/widgets/filter_grids/common/decorated_filter_chip.dart b/lib/widgets/filter_grids/common/decorated_filter_chip.dart index a0646b566..8501084e1 100644 --- a/lib/widgets/filter_grids/common/decorated_filter_chip.dart +++ b/lib/widgets/filter_grids/common/decorated_filter_chip.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -25,6 +26,7 @@ import 'package:provider/provider.dart'; class DecoratedFilterChip extends StatelessWidget { final CollectionFilter filter; final double extent; + final AvesEntry coverEntry; final bool pinned, highlightable; final FilterCallback onTap; final OffsetFilterCallback onLongPress; @@ -33,6 +35,7 @@ class DecoratedFilterChip extends StatelessWidget { Key key, @required this.filter, @required this.extent, + this.coverEntry, this.pinned = false, this.highlightable = true, this.onTap, @@ -76,7 +79,7 @@ class DecoratedFilterChip extends StatelessWidget { } Widget _buildChip(CollectionSource source) { - final entry = source.recentEntry(filter); + final entry = coverEntry ?? source.coverEntry(filter); final backgroundImage = entry == null ? Container(color: Colors.white) : entry.isSvg @@ -89,7 +92,7 @@ class DecoratedFilterChip extends StatelessWidget { extent: extent, ); final radius = min(AvesFilterChip.defaultRadius, extent / 4); - final titlePadding = min(6.0, extent / 16); + final titlePadding = min(4.0, extent / 32); final borderRadius = BorderRadius.all(Radius.circular(radius)); Widget child = AvesFilterChip( filter: filter, @@ -134,13 +137,13 @@ class DecoratedFilterChip extends StatelessWidget { if (pinned) AnimatedPadding( padding: EdgeInsets.only(right: padding), + duration: Durations.chipDecorationAnimation, child: DecoratedIcon( AIcons.pin, color: FilterGridPage.detailColor, shadows: [Constants.embossShadow], size: iconSize, ), - duration: Durations.chipDecorationAnimation, ), if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)) AnimatedPadding( diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index c36427f40..65833b2ba 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; @@ -14,8 +15,9 @@ import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/scaling.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; @@ -25,39 +27,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; +typedef QueryTest = Iterable> Function(Iterable> filters, String query); + class FilterGridPage extends StatelessWidget { + final String settingsRouteKey; final Widget appBar; + final double appBarHeight; final Map>> filterSections; final bool showHeaders; final ValueNotifier queryNotifier; + final QueryTest applyQuery; final Widget Function() emptyBuilder; - final String settingsRouteKey; - final Iterable> Function(Iterable> filters, String query) applyQuery; final FilterCallback onTap; final OffsetFilterCallback onLongPress; - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); - final ValueNotifier _tileExtentNotifier = ValueNotifier(0); - - static const columnCountDefault = 2; - static const extentMin = 60.0; - static const spacing = 8.0; - - FilterGridPage({ + const FilterGridPage({ Key key, + this.settingsRouteKey, @required this.appBar, + this.appBarHeight = kToolbarHeight, @required this.filterSections, - this.showHeaders = false, + @required this.showHeaders, @required this.queryNotifier, this.applyQuery, @required this.emptyBuilder, - this.settingsRouteKey, - double appBarHeight = kToolbarHeight, @required this.onTap, this.onLongPress, - }) : super(key: key) { - _appBarHeightNotifier.value = appBarHeight; - } + }) : super(key: key); static const Color detailColor = Color(0xFFE0E0E0); @@ -69,76 +65,20 @@ class FilterGridPage extends StatelessWidget { child: GestureAreaProtectorStack( child: SafeArea( bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final viewportSize = constraints.biggest; - assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); - if (viewportSize.isEmpty) return SizedBox.shrink(); - - final tileExtentManager = TileExtentManager( - settingsRouteKey: settingsRouteKey ?? context.currentRouteName, - extentNotifier: _tileExtentNotifier, - columnCountDefault: columnCountDefault, - extentMin: extentMin, - spacing: spacing, - )..applyTileExtent(viewportSize: viewportSize); - - return ValueListenableBuilder( - valueListenable: queryNotifier, - builder: (context, query, child) { - Map>> visibleFilterSections; - if (applyQuery == null) { - visibleFilterSections = filterSections; - } else { - visibleFilterSections = {}; - filterSections.forEach((sectionKey, sectionFilters) { - final visibleFilters = applyQuery(sectionFilters, query); - if (visibleFilters.isNotEmpty) { - visibleFilterSections[sectionKey] = visibleFilters.toList(); - } - }); - } - - final pinnedFilters = settings.pinnedFilters; - final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider( - sections: visibleFilterSections, - showHeaders: showHeaders, - scrollableWidth: viewportSize.width, - tileExtent: tileExtent, - columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), - spacing: spacing, - tileBuilder: (gridItem) { - final filter = gridItem.filter; - final entry = gridItem.entry; - return MetaData( - metaData: ScalerMetadata(FilterGridItem(filter, entry)), - child: DecoratedFilterChip( - key: Key(filter.key), - filter: filter, - extent: _tileExtentNotifier.value, - pinned: pinnedFilters.contains(filter), - onTap: onTap, - onLongPress: onLongPress, - ), - ); - }, - child: _SectionedContent( - appBar: appBar, - appBarHeightNotifier: _appBarHeightNotifier, - visibleFilterSections: visibleFilterSections, - emptyBuilder: emptyBuilder, - viewportSize: viewportSize, - tileExtentManager: tileExtentManager, - scrollController: PrimaryScrollController.of(context), - ), - ), - ); - return sectionedListLayoutProvider; - }, - ); - }, + child: AnimatedBuilder( + animation: covers, + builder: (context, child) => FilterGrid( + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: appBarHeight, + filterSections: filterSections, + showHeaders: showHeaders, + queryNotifier: queryNotifier, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + onTap: onTap, + onLongPress: onLongPress, + ), ), ), ), @@ -150,30 +90,173 @@ class FilterGridPage extends StatelessWidget { } } -class _SectionedContent extends StatefulWidget { +class FilterGrid extends StatefulWidget { + final String settingsRouteKey; + final Widget appBar; + final double appBarHeight; + final Map>> filterSections; + final bool showHeaders; + final ValueNotifier queryNotifier; + final QueryTest applyQuery; + final Widget Function() emptyBuilder; + final FilterCallback onTap; + final OffsetFilterCallback onLongPress; + + const FilterGrid({ + Key key, + @required this.settingsRouteKey, + @required this.appBar, + @required this.appBarHeight, + @required this.filterSections, + @required this.showHeaders, + @required this.queryNotifier, + @required this.applyQuery, + @required this.emptyBuilder, + @required this.onTap, + @required this.onLongPress, + }) : super(key: key); + + @override + _FilterGridState createState() => _FilterGridState(); +} + +class _FilterGridState extends State> { + TileExtentController _tileExtentController; + + @override + Widget build(BuildContext context) { + _tileExtentController ??= TileExtentController( + settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName, + columnCountDefault: 2, + extentMin: 60, + spacing: 8, + ); + return TileExtentControllerProvider( + controller: _tileExtentController, + child: _FilterGridContent( + appBar: widget.appBar, + appBarHeight: widget.appBarHeight, + filterSections: widget.filterSections, + showHeaders: widget.showHeaders, + queryNotifier: widget.queryNotifier, + applyQuery: widget.applyQuery, + emptyBuilder: widget.emptyBuilder, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + ), + ); + } +} + +class _FilterGridContent extends StatelessWidget { + final Widget appBar; + final Map>> filterSections; + final bool showHeaders; + final ValueNotifier queryNotifier; + final Widget Function() emptyBuilder; + final QueryTest applyQuery; + final FilterCallback onTap; + final OffsetFilterCallback onLongPress; + + final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); + + _FilterGridContent({ + Key key, + @required this.appBar, + @required double appBarHeight, + @required this.filterSections, + @required this.showHeaders, + @required this.queryNotifier, + @required this.applyQuery, + @required this.emptyBuilder, + @required this.onTap, + @required this.onLongPress, + }) : super(key: key) { + _appBarHeightNotifier.value = appBarHeight; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, child) { + Map>> visibleFilterSections; + if (applyQuery == null) { + visibleFilterSections = filterSections; + } else { + visibleFilterSections = {}; + filterSections.forEach((sectionKey, sectionFilters) { + final visibleFilters = applyQuery(sectionFilters, query); + if (visibleFilters.isNotEmpty) { + visibleFilterSections[sectionKey] = visibleFilters.toList(); + } + }); + } + + final pinnedFilters = settings.pinnedFilters; + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: context.select>((controller) => controller.extentNotifier), + builder: (context, tileExtent, child) { + final columnCount = context.select((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)); + final tileSpacing = context.select((controller) => controller.spacing); + return SectionedFilterListLayoutProvider( + sections: visibleFilterSections, + showHeaders: showHeaders, + scrollableWidth: context.select((controller) => controller.viewportSize.width), + tileExtent: tileExtent, + columnCount: columnCount, + spacing: tileSpacing, + tileBuilder: (gridItem) { + final filter = gridItem.filter; + final entry = gridItem.entry; + return MetaData( + metaData: ScalerMetadata(FilterGridItem(filter, entry)), + child: DecoratedFilterChip( + key: Key(filter.key), + filter: filter, + extent: tileExtent, + pinned: pinnedFilters.contains(filter), + onTap: onTap, + onLongPress: onLongPress, + ), + ); + }, + child: _FilterSectionedContent( + appBar: appBar, + appBarHeightNotifier: _appBarHeightNotifier, + visibleFilterSections: visibleFilterSections, + emptyBuilder: emptyBuilder, + scrollController: PrimaryScrollController.of(context), + ), + ); + }, + ); + return sectionedListLayoutProvider; + }, + ); + } +} + +class _FilterSectionedContent extends StatefulWidget { final Widget appBar; final ValueNotifier appBarHeightNotifier; final Map>> visibleFilterSections; final Widget Function() emptyBuilder; - final Size viewportSize; - final TileExtentManager tileExtentManager; final ScrollController scrollController; - const _SectionedContent({ + const _FilterSectionedContent({ @required this.appBar, @required this.appBarHeightNotifier, @required this.visibleFilterSections, @required this.emptyBuilder, - @required this.viewportSize, - @required this.tileExtentManager, @required this.scrollController, }); @override - _SectionedContentState createState() => _SectionedContentState(); + _FilterSectionedContentState createState() => _FilterSectionedContentState(); } -class _SectionedContentState extends State<_SectionedContent> { +class _FilterSectionedContentState extends State<_FilterSectionedContent> { Widget get appBar => widget.appBar; ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; @@ -182,10 +265,6 @@ class _SectionedContentState extends State<_Sectione Widget Function() get emptyBuilder => widget.emptyBuilder; - Size get viewportSize => widget.viewportSize; - - TileExtentManager get tileExtentManager => widget.tileExtentManager; - ScrollController get scrollController => widget.scrollController; final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); @@ -196,6 +275,27 @@ class _SectionedContentState extends State<_Sectione WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitHighlight()); } + @override + Widget build(BuildContext context) { + final scrollView = AnimationLimiter( + child: _FilterScrollView( + scrollableKey: _scrollableKey, + appBar: appBar, + appBarHeightNotifier: appBarHeightNotifier, + emptyBuilder: emptyBuilder, + scrollController: scrollController, + ), + ); + + final scaler = _FilterScaler( + scrollableKey: _scrollableKey, + appBarHeightNotifier: appBarHeightNotifier, + child: scrollView, + ); + + return scaler; + } + Future _checkInitHighlight() async { final highlightInfo = context.read(); final filter = highlightInfo.clear(); @@ -228,21 +328,31 @@ class _SectionedContentState extends State<_Sectione ); } } +} + +class _FilterScaler extends StatelessWidget { + final GlobalKey scrollableKey; + final ValueNotifier appBarHeightNotifier; + final Widget child; + + const _FilterScaler({ + @required this.scrollableKey, + @required this.appBarHeightNotifier, + @required this.child, + }); @override Widget build(BuildContext context) { final pinnedFilters = settings.pinnedFilters; - + final tileSpacing = context.select((controller) => controller.spacing); return GridScaleGestureDetector>( - tileExtentManager: tileExtentManager, - scrollableKey: _scrollableKey, + scrollableKey: scrollableKey, appBarHeightNotifier: appBarHeightNotifier, - viewportSize: viewportSize, gridBuilder: (center, extent, child) => CustomPaint( painter: GridPainter( center: center, extent: extent, - spacing: tileExtentManager.spacing, + spacing: tileSpacing, color: Colors.grey.shade700, ), child: child, @@ -261,11 +371,31 @@ class _SectionedContentState extends State<_Sectione return sectionedListLayout.getTileRect(item) ?? Rect.zero; }, onScaled: (item) => context.read().set(item.filter), - child: AnimationLimiter( - child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)), - ), + child: child, ); } +} + +class _FilterScrollView extends StatelessWidget { + final GlobalKey scrollableKey; + final Widget appBar; + final ValueNotifier appBarHeightNotifier; + final Widget Function() emptyBuilder; + final ScrollController scrollController; + + const _FilterScrollView({ + @required this.scrollableKey, + @required this.appBar, + @required this.appBarHeightNotifier, + @required this.emptyBuilder, + @required this.scrollController, + }); + + @override + Widget build(BuildContext context) { + final scrollView = _buildScrollView(context); + return _buildDraggableScrollView(scrollView); + } Widget _buildDraggableScrollView(ScrollView scrollView) { return Selector( @@ -288,31 +418,30 @@ class _SectionedContentState extends State<_Sectione ); } - ScrollView _buildScrollView(BuildContext context, bool empty) { - Widget content; - if (empty) { - content = SliverFillRemaining( - child: Selector( - selector: (context, mq) => mq.effectiveBottomPadding, - builder: (context, mqPaddingBottom, child) { - return Padding( - padding: EdgeInsets.only(bottom: mqPaddingBottom), - child: emptyBuilder(), - ); - }, - ), - hasScrollBody: false, - ); - } else { - content = SectionedListSliver>(); - } - + ScrollView _buildScrollView(BuildContext context) { return CustomScrollView( - key: _scrollableKey, + key: scrollableKey, controller: scrollController, slivers: [ appBar, - content, + Selector>, bool>( + selector: (context, layout) => layout.sections.isEmpty, + builder: (context, empty, child) { + return empty + ? SliverFillRemaining( + hasScrollBody: false, + child: Selector( + selector: (context, mq) => mq.effectiveBottomPadding, + builder: (context, mqPaddingBottom, child) { + return Padding( + padding: EdgeInsets.only(bottom: mqPaddingBottom), + child: emptyBuilder(), + ); + }, + ), + ) + : SectionedListSliver>(); + }), BottomPaddingSliver(), ], ); diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 5ac7e20da..e85d4a88c 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -1,6 +1,6 @@ import 'dart:ui'; -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_actions.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -12,6 +12,7 @@ import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; @@ -21,6 +22,7 @@ import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; class FilterNavigationPage extends StatelessWidget { final CollectionSource source; @@ -46,10 +48,11 @@ class FilterNavigationPage extends StatelessWidget { @override Widget build(BuildContext context) { + final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); return FilterGridPage( key: ValueKey('filter-grid-page'), appBar: SliverAppBar( - title: TappableAppBarTitle( + title: InteractiveAppBarTitle( onTap: () => _goToSearch(context), child: SourceStateAwareAppBarTitle( title: Text(title), @@ -79,21 +82,20 @@ class FilterNavigationPage extends StatelessWidget { )), ), ), - onLongPress: AvesApp.mode == AppMode.main ? _showMenu : null, + onLongPress: isMainMode ? _showMenu : null, ); } void _showMenu(BuildContext context, T filter, Offset tapPosition) async { final RenderBox overlay = Overlay.of(context).context.findRenderObject(); final touchArea = Size(40, 40); - // TODO TLAD check menu is within safe area, when this lands on stable: https://github.com/flutter/flutter/commit/cfc8ec23b633da1001359e384435e8333c9d3733 final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), items: chipActionsBuilder(filter) .map((action) => PopupMenuItem( value: action, - child: MenuRow(text: action.getText(), icon: action.getIcon()), + child: MenuRow(text: action.getText(context), icon: action.getIcon()), )) .toList(), ); @@ -113,21 +115,16 @@ class FilterNavigationPage extends StatelessWidget { PopupMenuItem( key: Key('menu-sort'), value: ChipSetAction.sort, - child: MenuRow(text: 'Sort…', icon: AIcons.sort), + child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort), ), if (groupable) PopupMenuItem( value: ChipSetAction.group, - child: MenuRow(text: 'Group…', icon: AIcons.group), - ), - if (kDebugMode) - PopupMenuItem( - value: ChipSetAction.refresh, - child: MenuRow(text: 'Refresh', icon: AIcons.refresh), + child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group), ), PopupMenuItem( value: ChipSetAction.stats, - child: MenuRow(text: 'Stats', icon: AIcons.stats), + child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats), ), ]; }, diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 1f672758f..2ba69df8a 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -1,6 +1,7 @@ import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -29,12 +30,15 @@ class ChipSectionKey extends SectionKey { class AlbumImportanceSectionKey extends ChipSectionKey { final AlbumImportance importance; - AlbumImportanceSectionKey._private(this.importance) : super(title: importance.getText()); + AlbumImportanceSectionKey._private(BuildContext context, this.importance) : super(title: importance.getText(context)); - static AlbumImportanceSectionKey pinned = AlbumImportanceSectionKey._private(AlbumImportance.pinned); - static AlbumImportanceSectionKey special = AlbumImportanceSectionKey._private(AlbumImportance.special); - static AlbumImportanceSectionKey apps = AlbumImportanceSectionKey._private(AlbumImportance.apps); - static AlbumImportanceSectionKey regular = AlbumImportanceSectionKey._private(AlbumImportance.regular); + factory AlbumImportanceSectionKey.pinned(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.pinned); + + factory AlbumImportanceSectionKey.special(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.special); + + factory AlbumImportanceSectionKey.apps(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.apps); + + factory AlbumImportanceSectionKey.regular(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.regular); @override Widget get leading => Icon(importance.getIcon()); @@ -43,16 +47,16 @@ class AlbumImportanceSectionKey extends ChipSectionKey { enum AlbumImportance { pinned, special, apps, regular } extension ExtraAlbumImportance on AlbumImportance { - String getText() { + String getText(BuildContext context) { switch (this) { case AlbumImportance.pinned: - return 'Pinned'; + return context.l10n.albumTierPinned; case AlbumImportance.special: - return 'Common'; + return context.l10n.albumTierSpecial; case AlbumImportance.apps: - return 'Apps'; + return context.l10n.albumTierApps; case AlbumImportance.regular: - return 'Others'; + return context.l10n.albumTierRegular; } return null; } @@ -75,7 +79,7 @@ extension ExtraAlbumImportance on AlbumImportance { class StorageVolumeSectionKey extends ChipSectionKey { final StorageVolume volume; - StorageVolumeSectionKey(this.volume) : super(title: volume?.description ?? 'Unknown'); + StorageVolumeSectionKey(BuildContext context, this.volume) : super(title: volume?.getDescription(context) ?? context.l10n.sectionUnknown); @override Widget get leading => (volume?.isRemovable ?? false) ? Icon(AIcons.removableStorage) : null; diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index e2d7a5b5a..774f4899d 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -6,7 +6,8 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; @@ -29,17 +30,18 @@ class CountryListPage extends StatelessWidget { stream: source.eventBus.on(), builder: (context, snapshot) => FilterNavigationPage( source: source, - title: 'Countries', + title: context.l10n.countryPageTitle, chipSetActionDelegate: CountryChipSetActionDelegate(), chipActionDelegate: ChipActionDelegate(), chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, + ChipAction.setCover, ChipAction.hide, ], filterSections: _getCountryEntries(source), emptyBuilder: () => EmptyContent( icon: AIcons.location, - text: 'No countries', + text: context.l10n.countryEmpty, ), ), ); diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index ee860fd6f..faf4b7cbb 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -6,7 +6,8 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; @@ -29,17 +30,18 @@ class TagListPage extends StatelessWidget { stream: source.eventBus.on(), builder: (context, snapshot) => FilterNavigationPage( source: source, - title: 'Tags', + title: context.l10n.tagPageTitle, chipSetActionDelegate: TagChipSetActionDelegate(), chipActionDelegate: ChipActionDelegate(), chipActionsBuilder: (filter) => [ settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, + ChipAction.setCover, ChipAction.hide, ], filterSections: _getTagEntries(source), emptyBuilder: () => EmptyContent( icon: AIcons.tag, - text: 'No tags', + text: context.l10n.tagEmpty, ), ), ); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 76da78c66..9e328365d 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -1,4 +1,4 @@ -import 'package:aves/main.dart'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/home_page.dart'; @@ -6,7 +6,7 @@ import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; @@ -66,7 +66,7 @@ class _HomePageState extends State { await androidFileUtils.init(); unawaited(androidFileUtils.initAppNames()); - AvesApp.mode = AppMode.main; + var appMode = AppMode.main; final intentData = widget.intentData ?? await ViewerService.getIntentData(); if (intentData?.isNotEmpty == true) { final action = intentData['action']; @@ -77,11 +77,11 @@ class _HomePageState extends State { mimeType: intentData['mimeType'], ); if (_viewerEntry != null) { - AvesApp.mode = AppMode.view; + appMode = AppMode.view; } break; case 'pick': - AvesApp.mode = AppMode.pick; + appMode = AppMode.pickExternal; // TODO TLAD apply pick mimetype(s) // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) String pickMimeTypes = intentData['mimeType']; @@ -97,19 +97,20 @@ class _HomePageState extends State { _shortcutFilters = extraFilters != null ? (extraFilters as List).cast() : null; } } - unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', AvesApp.mode.toString())); + context.read>().value = appMode; + unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', appMode.toString())); - if (AvesApp.mode != AppMode.view) { + if (appMode != AppMode.view) { final source = context.read(); await source.init(); unawaited(source.refresh()); } - unawaited(Navigator.pushReplacement(context, _getRedirectRoute())); + unawaited(Navigator.pushReplacement(context, _getRedirectRoute(appMode))); } Future _initViewerEntry({@required String uri, @required String mimeType}) async { - final entry = await ImageFileService.getEntry(uri, mimeType); + final entry = await imageFileService.getEntry(uri, mimeType); if (entry != null) { // cataloguing is essential for coordinates and video rotation await entry.catalog(); @@ -117,8 +118,8 @@ class _HomePageState extends State { return entry; } - Route _getRedirectRoute() { - if (AvesApp.mode == AppMode.view) { + Route _getRedirectRoute(AppMode appMode) { + if (appMode == AppMode.view) { return DirectMaterialPageRoute( settings: RouteSettings(name: EntryViewerPage.routeName), builder: (_) => EntryViewerPage( @@ -129,7 +130,7 @@ class _HomePageState extends State { String routeName; Iterable filters; - if (AvesApp.mode == AppMode.pick) { + if (appMode == AppMode.pickExternal) { routeName = CollectionPage.routeName; } else { routeName = _shortcutRouteName ?? settings.homePage.routeName; diff --git a/lib/widgets/search/expandable_filter_row.dart b/lib/widgets/search/expandable_filter_row.dart index c5501f33f..07eba3dc2 100644 --- a/lib/widgets/search/expandable_filter_row.dart +++ b/lib/widgets/search/expandable_filter_row.dart @@ -45,7 +45,7 @@ class ExpandableFilterRow extends StatelessWidget { IconButton( icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), onPressed: () => expandedNotifier.value = isExpanded ? null : title, - tooltip: isExpanded ? 'Collapse' : 'Expand', + tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, ), ], ), @@ -90,13 +90,13 @@ class ExpandableFilterRow extends StatelessWidget { titleRow, AnimatedSwitcher( duration: Durations.filterRowExpandAnimation, - child: filterChips, layoutBuilder: (currentChild, previousChildren) => Stack( children: [ ...previousChildren, if (currentChild != null) currentChild, ], ), + child: filterChips, ), ], ) @@ -105,7 +105,7 @@ class ExpandableFilterRow extends StatelessWidget { Widget _buildFilterChip(CollectionFilter filter) { return AvesFilterChip( - // key `album-...` is expected by test driver + // key `album-{path}` is expected by test driver key: Key(filter.key), filter: filter, heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart index 4e99cc457..3e377a670 100644 --- a/lib/widgets/search/search_button.dart +++ b/lib/widgets/search/search_button.dart @@ -16,7 +16,7 @@ class CollectionSearchButton extends StatelessWidget { key: Key('search-button'), icon: Icon(AIcons.search), onPressed: () => _goToSearch(context), - tooltip: 'Search', + tooltip: MaterialLocalizations.of(context).searchFieldLabel, ); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index dfcbc4a21..1d8caeefe 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -15,11 +15,13 @@ import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/search/expandable_filter_row.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; class CollectionSearchDelegate { final CollectionSource source; @@ -27,6 +29,16 @@ class CollectionSearchDelegate { final ValueNotifier expandedSectionNotifier = ValueNotifier(null); static const searchHistoryCount = 10; + static final typeFilters = [ + FavouriteFilter(), + MimeFilter(MimeTypes.anyImage), + MimeFilter(MimeTypes.anyVideo), + MimeFilter(MimeTypes.svg), + TypeFilter(TypeFilter.animated), + TypeFilter(TypeFilter.panorama), + TypeFilter(TypeFilter.sphericalVideo), + TypeFilter(TypeFilter.geotiff), + ]; CollectionSearchDelegate({@required this.source, this.parentCollection}); @@ -58,7 +70,7 @@ class CollectionSearchDelegate { query = ''; showSuggestions(context); }, - tooltip: 'Clear', + tooltip: context.l10n.clearTooltip, ), ]; } @@ -71,84 +83,82 @@ class CollectionSearchDelegate { valueListenable: expandedSectionNotifier, builder: (context, expandedSection, child) { final queryFilter = _buildQueryFilter(false); - final history = settings.searchHistory; - return ListView( - padding: EdgeInsets.only(top: 8), - children: [ - _buildFilterRow( - context: context, - filters: [ - queryFilter, - FavouriteFilter(), - MimeFilter(MimeTypes.anyImage), - MimeFilter(MimeTypes.anyVideo), - MimeFilter(MimeTypes.svg), - TypeFilter(TypeFilter.animated), - TypeFilter(TypeFilter.panorama), - TypeFilter(TypeFilter.sphericalVideo), - TypeFilter(TypeFilter.geotiff), - ].where((f) => f != null && containQuery(f.label)).toList(), - // usually perform hero animation only on tapped chips, - // but we also need to animate the query chip when it is selected by submitting the search query - heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap, - ), - if (upQuery.isEmpty && history.isNotEmpty) - _buildFilterRow( - context: context, - title: 'Recent', - filters: history, - ), - StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - // filter twice: full path, and then unique name - final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(s))).where((f) => containQuery(f.uniqueName)).toList()..sort(); - return _buildFilterRow( + return Selector>( + selector: (context, s) => s.hiddenFilters, + builder: (context, hiddenFilters, child) { + bool notHidden(CollectionFilter filter) => !hiddenFilters.contains(filter); + final history = settings.searchHistory.where(notHidden).toList(); + return ListView( + padding: EdgeInsets.only(top: 8), + children: [ + _buildFilterRow( context: context, - title: 'Albums', - filters: filters, - ); - }), - StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)).toList(); - return _buildFilterRow( - context: context, - title: 'Countries', - filters: filters, - ); - }), - StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final filters = source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)); - final noFilter = LocationFilter(LocationLevel.place, ''); - return _buildFilterRow( - context: context, - title: 'Places', filters: [ - if (containQuery(LocationFilter.emptyLabel)) noFilter, - ...filters, - ], - ); - }), - StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final filters = source.sortedTags.where(containQuery).map((s) => TagFilter(s)); - final noFilter = TagFilter(''); - return _buildFilterRow( - context: context, - title: 'Tags', - filters: [ - if (containQuery(TagFilter.emptyLabel)) noFilter, - ...filters, - ], - ); - }), - ], - ); + queryFilter, + ...typeFilters.where(notHidden), + ].where((f) => f != null && containQuery(f.getLabel(context))).toList(), + // usually perform hero animation only on tapped chips, + // but we also need to animate the query chip when it is selected by submitting the search query + heroTypeBuilder: (filter) => filter == queryFilter ? HeroType.always : HeroType.onTap, + ), + if (upQuery.isEmpty && history.isNotEmpty) + _buildFilterRow( + context: context, + title: context.l10n.searchSectionRecent, + filters: history, + ), + StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + // filter twice: full path, and then unique name + final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(context, s))).where((f) => containQuery(f.uniqueName)).toList()..sort(); + return _buildFilterRow( + context: context, + title: context.l10n.searchSectionAlbums, + filters: filters, + ); + }), + StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final filters = source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)).toList(); + return _buildFilterRow( + context: context, + title: context.l10n.searchSectionCountries, + filters: filters, + ); + }), + StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final filters = source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)); + final noFilter = LocationFilter(LocationLevel.place, ''); + return _buildFilterRow( + context: context, + title: context.l10n.searchSectionPlaces, + filters: [ + if (containQuery(noFilter.getLabel(context))) noFilter, + ...filters, + ], + ); + }), + StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final filters = source.sortedTags.where(containQuery).map((s) => TagFilter(s)); + final noFilter = TagFilter(''); + return _buildFilterRow( + context: context, + title: context.l10n.searchSectionTags, + filters: [ + if (containQuery(noFilter.getLabel(context))) noFilter, + ...filters, + ], + ); + }), + ], + ); + }); }), ); } diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index e85ced83d..89e68656a 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -1,5 +1,6 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,8 +12,8 @@ class SearchPage extends StatefulWidget { final Animation animation; const SearchPage({ - this.delegate, - this.animation, + @required this.delegate, + @required this.animation, }); @override @@ -118,7 +119,7 @@ class _SearchPageState extends State { onSubmitted: (_) => widget.delegate.showResults(context), decoration: InputDecoration( border: InputBorder.none, - hintText: 'Search collection', + hintText: context.l10n.searchCollectionFieldHint, hintStyle: theme.inputDecorationTheme.hintStyle, ), ), diff --git a/lib/widgets/settings/access_grants.dart b/lib/widgets/settings/access_grants.dart index ef9bb7808..d69ad1b1a 100644 --- a/lib/widgets/settings/access_grants.dart +++ b/lib/widgets/settings/access_grants.dart @@ -1,13 +1,14 @@ import 'package:aves/services/android_file_service.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:flutter/material.dart'; class StorageAccessTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - title: Text('Storage Access'), + title: Text(context.l10n.settingsStorageAccessTile), onTap: () { Navigator.push( context, @@ -44,7 +45,7 @@ class _StorageAccessPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Storage Access'), + title: Text(context.l10n.settingsStorageAccessTitle), ), body: SafeArea( child: Column( @@ -56,7 +57,7 @@ class _StorageAccessPageState extends State { children: [ Icon(AIcons.info), SizedBox(width: 16), - Expanded(child: Text('Some directories require an explicit access grant to modify files in them. You can review here directories to which you previously gave access.')), + Expanded(child: Text(context.l10n.settingsStorageAccessBanner)), ], ), ), @@ -74,7 +75,7 @@ class _StorageAccessPageState extends State { _lastPaths = snapshot.data..sort(); if (_lastPaths.isEmpty) { return EmptyContent( - text: 'No access grants', + text: context.l10n.settingsStorageAccessEmpty, ); } return Column( @@ -90,7 +91,7 @@ class _StorageAccessPageState extends State { _load(); setState(() {}); }, - tooltip: 'Revoke', + tooltip: context.l10n.settingsStorageAccessRevokeTooltip, ), )) .toList(), diff --git a/lib/widgets/settings/entry_background.dart b/lib/widgets/settings/entry_background.dart index 51b0a9326..b0f1995f0 100644 --- a/lib/widgets/settings/entry_background.dart +++ b/lib/widgets/settings/entry_background.dart @@ -1,4 +1,5 @@ import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/settings/hidden_filters.dart b/lib/widgets/settings/hidden_filters.dart index 09d57a2a6..526b353f8 100644 --- a/lib/widgets/settings/hidden_filters.dart +++ b/lib/widgets/settings/hidden_filters.dart @@ -1,8 +1,9 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -10,7 +11,7 @@ class HiddenFilterTile extends StatelessWidget { @override Widget build(BuildContext context) { return ListTile( - title: Text('Hidden filters'), + title: Text(context.l10n.settingsHiddenFiltersTile), onTap: () { Navigator.push( context, @@ -31,7 +32,7 @@ class HiddenFilterPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Hidden Filters'), + title: Text(context.l10n.settingsHiddenFiltersTitle), ), body: SafeArea( child: Column( @@ -43,7 +44,7 @@ class HiddenFilterPage extends StatelessWidget { children: [ Icon(AIcons.info), SizedBox(width: 16), - Expanded(child: Text('Photos and videos matching hidden filters will not appear in your collection.')), + Expanded(child: Text(context.l10n.settingsHiddenFiltersBanner)), ], ), ), @@ -58,7 +59,7 @@ class HiddenFilterPage extends StatelessWidget { if (hiddenFilters.isEmpty) { return EmptyContent( icon: AIcons.hide, - text: 'No hidden filters', + text: context.l10n.settingsHiddenFiltersEmpty, ); } return Wrap( diff --git a/lib/widgets/settings/language.dart b/lib/widgets/settings/language.dart new file mode 100644 index 000000000..ca4e1599e --- /dev/null +++ b/lib/widgets/settings/language.dart @@ -0,0 +1,52 @@ +import 'dart:collection'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; + +class LanguageTile extends StatelessWidget { + final Locale _systemLocale = WidgetsBinding.instance.window.locale; + + static const _systemLocaleOption = Locale('system'); + + @override + Widget build(BuildContext context) { + final current = settings.locale; + return ListTile( + title: Text(context.l10n.settingsLanguage), + subtitle: Text('${current == null ? '${context.l10n.settingsSystemDefault} • ${_getLocaleName(_systemLocale)}' : _getLocaleName(current)}'), + onTap: () async { + final value = await showDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: settings.locale ?? _systemLocaleOption, + options: _getLocaleOptions(context), + optionSubtitleBuilder: (locale) => locale == _systemLocaleOption ? _getLocaleName(_systemLocale) : null, + title: context.l10n.settingsLanguage, + ), + ); + if (value != null) { + settings.locale = value == _systemLocaleOption ? null : value; + } + }, + ); + } + + String _getLocaleName(Locale locale) => LocaleNamesLocalizationsDelegate.nativeLocaleNames[locale.toString()]; + + LinkedHashMap _getLocaleOptions(BuildContext context) { + final supportedLocales = List.from(AppLocalizations.supportedLocales); + supportedLocales.removeWhere((locale) => locale == _systemLocale); + final displayLocales = supportedLocales.map((locale) => MapEntry(locale, _getLocaleName(locale))).toList()..sort((a, b) => compareAsciiUpperCase(a.value, b.value)); + + return LinkedHashMap.of({ + _systemLocaleOption: context.l10n.settingsSystemDefault, + ...LinkedHashMap.fromEntries(displayLocales), + }); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 695854bc6..77a4d516b 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -1,15 +1,18 @@ import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; import 'package:aves/widgets/settings/entry_background.dart'; import 'package:aves/widgets/settings/hidden_filters.dart'; +import 'package:aves/widgets/settings/language.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -30,7 +33,7 @@ class _SettingsPageState extends State { return MediaQueryDataProvider( child: Scaffold( appBar: AppBar( - title: Text('Settings'), + title: Text(context.l10n.settingsPageTitle), ), body: Theme( data: theme.copyWith( @@ -73,19 +76,19 @@ class _SettingsPageState extends State { Widget _buildNavigationSection(BuildContext context) { return AvesExpansionTile( - title: 'Navigation', + title: context.l10n.settingsSectionNavigation, expandedNotifier: _expandedNotifier, children: [ ListTile( - title: Text('Home'), - subtitle: Text(settings.homePage.name), + title: Text(context.l10n.settingsHome), + subtitle: Text(settings.homePage.getName(context)), onTap: () async { final value = await showDialog( context: context, builder: (context) => AvesSelectionDialog( initialValue: settings.homePage, - options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.name))), - title: 'Home', + options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.settingsHome, ), ); if (value != null) { @@ -96,7 +99,7 @@ class _SettingsPageState extends State { SwitchListTile( value: settings.mustBackTwiceToExit, onChanged: (v) => settings.mustBackTwiceToExit = v, - title: Text('Tap “back” twice to exit'), + title: Text(context.l10n.settingsDoubleBackExit), ), ], ); @@ -104,19 +107,20 @@ class _SettingsPageState extends State { Widget _buildDisplaySection(BuildContext context) { return AvesExpansionTile( - title: 'Display', + title: context.l10n.settingsSectionDisplay, expandedNotifier: _expandedNotifier, children: [ + LanguageTile(), ListTile( - title: Text('Keep screen on'), - subtitle: Text(settings.keepScreenOn.name), + title: Text(context.l10n.settingsKeepScreenOnTile), + subtitle: Text(settings.keepScreenOn.getName(context)), onTap: () async { final value = await showDialog( context: context, builder: (context) => AvesSelectionDialog( initialValue: settings.keepScreenOn, - options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.name))), - title: 'Keep Screen On', + options: Map.fromEntries(KeepScreenOn.values.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.settingsKeepScreenOnTitle, ), ); if (value != null) { @@ -125,34 +129,30 @@ class _SettingsPageState extends State { }, ), ListTile( - title: Text('Raster image background'), + title: Text(context.l10n.settingsRasterImageBackground), trailing: EntryBackgroundSelector( getter: () => settings.rasterBackground, setter: (value) => settings.rasterBackground = value, ), ), ListTile( - title: Text('Vector image background'), + title: Text(context.l10n.settingsVectorImageBackground), trailing: EntryBackgroundSelector( getter: () => settings.vectorBackground, setter: (value) => settings.vectorBackground = value, ), ), ListTile( - title: Text('Coordinate format'), - subtitle: Text(settings.coordinateFormat.name), + title: Text(context.l10n.settingsCoordinateFormatTile), + subtitle: Text(settings.coordinateFormat.getName(context)), onTap: () async { final value = await showDialog( context: context, builder: (context) => AvesSelectionDialog( initialValue: settings.coordinateFormat, - options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.name))), - optionSubtitleBuilder: (dynamic value) { - // dynamic declaration followed by cast, as workaround for generics limitation - final formatter = (value as CoordinateFormat); - return formatter.format(Constants.pointNemo); - }, - title: 'Coordinate Format', + options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.getName(context)))), + optionSubtitleBuilder: (value) => value.format(Constants.pointNemo), + title: context.l10n.settingsCoordinateFormatTitle, ), ); if (value != null) { @@ -166,23 +166,23 @@ class _SettingsPageState extends State { Widget _buildThumbnailsSection(BuildContext context) { return AvesExpansionTile( - title: 'Thumbnails', + title: context.l10n.settingsSectionThumbnails, expandedNotifier: _expandedNotifier, children: [ SwitchListTile( value: settings.showThumbnailLocation, onChanged: (v) => settings.showThumbnailLocation = v, - title: Text('Show location icon'), + title: Text(context.l10n.settingsThumbnailShowLocationIcon), ), SwitchListTile( value: settings.showThumbnailRaw, onChanged: (v) => settings.showThumbnailRaw = v, - title: Text('Show raw icon'), + title: Text(context.l10n.settingsThumbnailShowRawIcon), ), SwitchListTile( value: settings.showThumbnailVideoDuration, onChanged: (v) => settings.showThumbnailVideoDuration = v, - title: Text('Show video duration'), + title: Text(context.l10n.settingsThumbnailShowVideoDuration), ), ], ); @@ -190,24 +190,24 @@ class _SettingsPageState extends State { Widget _buildViewerSection(BuildContext context) { return AvesExpansionTile( - title: 'Viewer', + title: context.l10n.settingsSectionViewer, expandedNotifier: _expandedNotifier, children: [ SwitchListTile( value: settings.showOverlayMinimap, onChanged: (v) => settings.showOverlayMinimap = v, - title: Text('Show minimap'), + title: Text(context.l10n.settingsViewerShowMinimap), ), SwitchListTile( value: settings.showOverlayInfo, onChanged: (v) => settings.showOverlayInfo = v, - title: Text('Show information'), - subtitle: Text('Show title, date, location, etc.'), + title: Text(context.l10n.settingsViewerShowInformation), + subtitle: Text(context.l10n.settingsViewerShowInformationSubtitle), ), SwitchListTile( value: settings.showOverlayShootingDetails, onChanged: settings.showOverlayInfo ? (v) => settings.showOverlayShootingDetails = v : null, - title: Text('Show shooting details'), + title: Text(context.l10n.settingsViewerShowShootingDetails), ), ], ); @@ -215,7 +215,7 @@ class _SettingsPageState extends State { Widget _buildSearchSection(BuildContext context) { return AvesExpansionTile( - title: 'Search', + title: context.l10n.settingsSectionSearch, expandedNotifier: _expandedNotifier, children: [ SwitchListTile( @@ -226,7 +226,7 @@ class _SettingsPageState extends State { settings.searchHistory = []; } }, - title: Text('Save search history'), + title: Text(context.l10n.settingsSaveSearchHistory), ), ], ); @@ -234,13 +234,13 @@ class _SettingsPageState extends State { Widget _buildPrivacySection(BuildContext context) { return AvesExpansionTile( - title: 'Privacy', + title: context.l10n.settingsSectionPrivacy, expandedNotifier: _expandedNotifier, children: [ SwitchListTile( value: settings.isCrashlyticsEnabled, onChanged: (v) => settings.isCrashlyticsEnabled = v, - title: Text('Allow anonymous analytics and crash reporting'), + title: Text(context.l10n.settingsEnableAnalytics), ), HiddenFilterTile(), StorageAccessTile(), diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index 97964940e..9cdfaac47 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -43,7 +43,7 @@ class FilterTable extends StatelessWidget { return Table( children: sortedEntries.take(5).map((kv) { final filter = filterBuilder(kv.key); - final label = filter.label; + final label = filter.getLabel(context); final count = kv.value; final percent = count / totalEntryCount; return TableRow( diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 1005b08b7..a411a22f1 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -12,7 +12,8 @@ import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/stats/filter_table.dart'; import 'package:charts_flutter/flutter.dart' as charts; @@ -62,24 +63,25 @@ class StatsPage extends StatelessWidget { if (entries.isEmpty) { child = EmptyContent( icon: AIcons.image, - text: 'No images', + text: context.l10n.collectionEmptyImages, ); } else { final byMimeTypes = groupBy(entries, (entry) => entry.mimeType).map((k, v) => MapEntry(k, v.length)); - final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image/'))); - final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video/'))); + final imagesByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('image'))); + final videoByMimeTypes = Map.fromEntries(byMimeTypes.entries.where((kv) => kv.key.startsWith('video'))); final mimeDonuts = Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - _buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'image', other: 'images'), imagesByMimeTypes), - _buildMimeDonut(context, (sum) => Intl.plural(sum, one: 'video', other: 'videos'), videoByMimeTypes), + _buildMimeDonut(context, (sum) => context.l10n.statsImage(sum), imagesByMimeTypes), + _buildMimeDonut(context, (sum) => context.l10n.statsVideo(sum), videoByMimeTypes), ], ); final catalogued = entries.where((entry) => entry.isCatalogued); final withGps = catalogued.where((entry) => entry.hasGps); - final withGpsPercent = withGps.length / entries.length; + final withGpsCount = withGps.length; + final withGpsPercent = withGpsCount / entries.length; final textScaleFactor = MediaQuery.textScaleFactorOf(context); final lineHeight = 16 * textScaleFactor; final locationIndicator = Padding( @@ -101,7 +103,7 @@ class StatsPage extends StatelessWidget { ), ), SizedBox(height: 8), - Text('${withGps.length} ${Intl.plural(withGps.length, one: 'item', other: 'items')} with location'), + Text(context.l10n.statsWithGps(withGpsCount)), ], ), ); @@ -109,16 +111,16 @@ class StatsPage extends StatelessWidget { children: [ mimeDonuts, locationIndicator, - ..._buildTopFilters(context, 'Top Countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), - ..._buildTopFilters(context, 'Top Places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), - ..._buildTopFilters(context, 'Top Tags', entryCountPerTag, (s) => TagFilter(s)), + ..._buildTopFilters(context, context.l10n.statsTopCountries, entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), + ..._buildTopFilters(context, context.l10n.statsTopPlaces, entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), + ..._buildTopFilters(context, context.l10n.statsTopTags, entryCountPerTag, (s) => TagFilter(s)), ], ); } return MediaQueryDataProvider( child: Scaffold( appBar: AppBar( - title: Text('Stats'), + title: Text(context.l10n.statsPageTitle), ), body: SafeArea( child: child, diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index ad5df9358..e586c51d2 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -1,6 +1,6 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/metadata.dart'; -import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/debug_page.dart b/lib/widgets/viewer/debug_page.dart index b5854fe88..cd7300585 100644 --- a/lib/widgets/viewer/debug_page.dart +++ b/lib/widgets/viewer/debug_page.dart @@ -1,5 +1,5 @@ +import 'package:aves/app_mode.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; -import 'package:aves/main.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/theme/icons.dart'; @@ -8,6 +8,7 @@ import 'package:aves/widgets/viewer/debug/metadata.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ViewerDebugPage extends StatelessWidget { @@ -21,7 +22,7 @@ class ViewerDebugPage extends StatelessWidget { Widget build(BuildContext context) { final tabs = >[ Tuple2(Tab(text: 'Entry'), _buildEntryTabView()), - if (AvesApp.mode != AppMode.view) Tuple2(Tab(text: 'DB'), DbTab(entry: entry)), + if (context.select, bool>((vn) => vn.value != AppMode.view)) Tuple2(Tab(text: 'DB'), DbTab(entry: entry)), Tuple2(Tab(icon: Icon(AIcons.android)), MetadataTab(entry: entry)), Tuple2(Tab(icon: Icon(AIcons.image)), _buildThumbnailsTabView()), ]; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index c85c8cc86..232740c53 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -6,12 +6,12 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_op_events.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/rename_entry_dialog.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart'; @@ -21,7 +21,6 @@ import 'package:aves/widgets/viewer/printer.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import 'package:intl/intl.dart'; import 'package:pedantic/pedantic.dart'; import 'package:provider/provider.dart'; @@ -103,14 +102,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkStoragePermission(context, {entry})) return; final success = await entry.flip(); - if (!success) showFeedback(context, 'Failed'); + if (!success) showFeedback(context, context.l10n.genericFailureFeedback); } Future _rotate(BuildContext context, AvesEntry entry, {@required bool clockwise}) async { if (!await checkStoragePermission(context, {entry})) return; final success = await entry.rotate(clockwise: clockwise); - if (!success) showFeedback(context, 'Failed'); + if (!success) showFeedback(context, context.l10n.genericFailureFeedback); } Future _showDeleteDialog(BuildContext context, AvesEntry entry) async { @@ -119,15 +118,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix builder: (context) { return AvesDialog( context: context, - content: Text('Are you sure?'), + content: Text(context.l10n.deleteEntriesConfirmationDialogMessage(1)), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('Cancel'.toUpperCase()), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('Delete'.toUpperCase()), + child: Text(context.l10n.deleteButtonLabel), ), ], ); @@ -138,10 +137,10 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkStoragePermission(context, {entry})) return; if (!await entry.delete()) { - showFeedback(context, 'Failed'); + showFeedback(context, context.l10n.genericFailureFeedback); } else { if (hasCollection) { - collection.source.removeEntries({entry.uri}); + await collection.source.removeEntries({entry.uri}); } EntryDeletedNotification(entry).dispatch(context); } @@ -170,7 +169,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selection = {}; if (entry.isMultipage) { - final multiPageInfo = await MetadataService.getMultiPageInfo(entry); + final multiPageInfo = await metadataService.getMultiPageInfo(entry); if (multiPageInfo.pageCount > 1) { for (final page in multiPageInfo.pages) { final pageEntry = entry.getPageEntry(page, eraseDefaultPageId: false); @@ -184,16 +183,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selectionCount = selection.length; showOpReport( context: context, - opStream: ImageFileService.export(selection, destinationAlbum: destinationAlbum), + opStream: imageFileService.export(selection, destinationAlbum: destinationAlbum), itemCount: selectionCount, onDone: (processed) { final movedOps = processed.where((e) => e.success); final movedCount = movedOps.length; if (movedCount < selectionCount) { final count = selectionCount - movedCount; - showFeedback(context, 'Failed to export ${Intl.plural(count, one: '$count page', other: '$count pages')}'); + showFeedback(context, context.l10n.collectionExportFailureFeedback(count)); } else { - showFeedback(context, 'Done!'); + showFeedback(context, context.l10n.genericSuccessFeedback); } }, ); @@ -208,7 +207,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix if (!await checkStoragePermission(context, {entry})) return; - showFeedback(context, await entry.rename(newName) ? 'Done!' : 'Failed'); + final success = await context.read().renameEntry(entry, newName); + + if (success) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } } void _goToSourceViewer(BuildContext context, AvesEntry entry) { @@ -217,7 +222,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix MaterialPageRoute( settings: RouteSettings(name: SourceViewerPage.routeName), builder: (context) => SourceViewerPage( - loader: () => ImageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode), + loader: () => imageFileService.getSvg(entry.uri, entry.mimeType).then(utf8.decode), ), ), ); diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 9f0cff0b6..f4bfe6799 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -1,11 +1,11 @@ import 'dart:math'; -import 'package:aves/model/availability.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/settings/screen_on.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/services/window_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/change_notifier.dart'; @@ -171,41 +171,40 @@ class _EntryViewerStackState extends State with SingleTickerPr return SynchronousFuture(false); }, child: ValueListenableProvider.value( - value: _heroInfoNotifier, - builder: (context, snapshot) { - return NotificationListener( - onNotification: (notification) { - if (notification is FilterNotification) { - _goToCollection(notification.filter); - } else if (notification is ViewStateNotification) { - _updateViewState(notification.uri, notification.viewState); - } else if (notification is EntryDeletedNotification) { - _onEntryDeleted(context, notification.entry); - } - return false; - }, - child: Stack( - children: [ - ViewerVerticalPageView( - collection: collection, - entryNotifier: _entryNotifier, - videoControllers: _videoControllers, - multiPageControllers: _multiPageControllers, - verticalPager: _verticalPager, - horizontalPager: _horizontalPager, - onVerticalPageChanged: _onVerticalPageChanged, - onHorizontalPageChanged: _onHorizontalPageChanged, - onImageTap: () => _overlayVisible.value = !_overlayVisible.value, - onImagePageRequested: () => _goToVerticalPage(imagePage), - onViewDisposed: (uri) => _updateViewState(uri, null), - ), - _buildTopOverlay(), - _buildBottomOverlay(), - BottomGestureAreaProtector(), - ], + value: _heroInfoNotifier, + child: NotificationListener( + onNotification: (notification) { + if (notification is FilterNotification) { + _goToCollection(notification.filter); + } else if (notification is ViewStateNotification) { + _updateViewState(notification.uri, notification.viewState); + } else if (notification is EntryDeletedNotification) { + _onEntryDeleted(context, notification.entry); + } + return false; + }, + child: Stack( + children: [ + ViewerVerticalPageView( + collection: collection, + entryNotifier: _entryNotifier, + videoControllers: _videoControllers, + multiPageControllers: _multiPageControllers, + verticalPager: _verticalPager, + horizontalPager: _horizontalPager, + onVerticalPageChanged: _onVerticalPageChanged, + onHorizontalPageChanged: _onHorizontalPageChanged, + onImageTap: () => _overlayVisible.value = !_overlayVisible.value, + onImagePageRequested: () => _goToVerticalPage(imagePage), + onViewDisposed: (uri) => _updateViewState(uri, null), ), - ); - }), + _buildTopOverlay(), + _buildBottomOverlay(), + BottomGestureAreaProtector(), + ], + ), + ), + ), ); } @@ -315,9 +314,7 @@ class _EntryViewerStackState extends State with SingleTickerPr return AnimatedBuilder( animation: _verticalScrollNotifier, builder: (context, child) => Positioned( - // TODO TLAD replace when using Flutter version adapted for null safety - // bottom: (_verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight, - bottom: (_verticalPager.offset ?? 0) - mqHeight, + bottom: (_verticalPager.position.hasPixels ? _verticalPager.offset : 0) - mqHeight, child: child, ), child: child, @@ -363,10 +360,10 @@ class _EntryViewerStackState extends State with SingleTickerPr ); } - Future _onVerticalPageChanged(int page) async { + void _onVerticalPageChanged(int page) { _currentVerticalPage.value = page; if (page == transitionPage) { - await _actionDelegate.dismissFeedback(); + _actionDelegate.dismissFeedback(context); _popVisual(); } else if (page == infoPage) { // prevent hero when viewer is offscreen @@ -462,7 +459,7 @@ class _EntryViewerStackState extends State with SingleTickerPr if (_overlayVisible.value) { _showSystemUI(); if (animate) { - _overlayAnimationController.forward(); + await _overlayAnimationController.forward(); } else { _overlayAnimationController.value = _overlayAnimationController.upperBound; } diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 8c7c39e86..6e3a2cd38 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -1,6 +1,6 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourite_repo.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; @@ -8,10 +8,10 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/utils/constants.dart'; import 'package:aves/utils/file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:collection/collection.dart'; @@ -40,37 +40,40 @@ class BasicSection extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; + final infoUnknown = l10n.viewerInfoUnknown; final date = entry.bestDate; - final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.infoUnknown; + final locale = l10n.localeName; + final dateText = date != null ? '${DateFormat.yMMMd(locale).format(date)} • ${DateFormat.Hm(locale).format(date)}' : infoUnknown; // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) - final title = entry.bestTitle ?? Constants.infoUnknown; - final uri = entry.uri ?? Constants.infoUnknown; + final title = entry.bestTitle ?? infoUnknown; + final uri = entry.uri ?? infoUnknown; final path = entry.path; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InfoRowGroup({ - 'Title': title, - 'Date': dateText, - if (entry.isVideo) ..._buildVideoRows(), - if (!entry.isSvg && entry.isSized) 'Resolution': rasterResolutionText, - 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.infoUnknown, - 'URI': uri, - if (path != null) 'Path': path, + l10n.viewerInfoLabelTitle: title, + l10n.viewerInfoLabelDate: dateText, + if (entry.isVideo) ..._buildVideoRows(context), + if (!entry.isSvg && entry.isSized) l10n.viewerInfoLabelResolution: rasterResolutionText, + l10n.viewerInfoLabelSize: entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : infoUnknown, + l10n.viewerInfoLabelUri: uri, + if (path != null) l10n.viewerInfoLabelPath: path, }), OwnerProp( entry: entry, visibleNotifier: visibleNotifier, ), - _buildChips(), + _buildChips(context), ], ); } - Widget _buildChips() { + Widget _buildChips(BuildContext context) { final tags = entry.xmpSubjects..sort(compareAsciiUpperCase); final album = entry.directory; final filters = { @@ -80,11 +83,11 @@ class BasicSection extends StatelessWidget { if (entry.isImage && entry.is360) TypeFilter(TypeFilter.panorama), if (entry.isVideo && entry.is360) TypeFilter(TypeFilter.sphericalVideo), if (entry.isVideo && !entry.is360) MimeFilter(MimeTypes.anyVideo), - if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)), + if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(context, album)), ...tags.map((tag) => TagFilter(tag)), }; return AnimatedBuilder( - animation: favourites.changeNotifier, + animation: favourites, builder: (context, child) { final effectiveFilters = [ ...filters, @@ -108,9 +111,9 @@ class BasicSection extends StatelessWidget { ); } - Map _buildVideoRows() { + Map _buildVideoRows(BuildContext context) { return { - 'Duration': entry.durationText, + context.l10n.viewerInfoLabelDuration: entry.durationText, }; } } @@ -180,23 +183,26 @@ class _OwnerPropState extends State { TextSpan( children: [ TextSpan( - text: 'Owned by', + text: context.l10n.viewerInfoLabelOwner, style: InfoRowGroup.keyStyle, ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Image( - image: AppIconImage( - packageName: _ownerPackage, - size: iconSize, + // `com.android.shell` is the package reported + // for images copied to the device by ADB for Test Driver + if (_ownerPackage != 'com.android.shell') + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Image( + image: AppIconImage( + packageName: _ownerPackage, + size: iconSize, + ), + width: iconSize, + height: iconSize, ), - width: iconSize, - height: iconSize, ), ), - ), TextSpan( text: appName, style: InfoRowGroup.baseStyle, @@ -212,7 +218,7 @@ class _OwnerPropState extends State { if (entry == null) return; if (_loadedUri.value == entry.uri) return; if (isVisible) { - _ownerPackage = await MetadataService.getContentResolverProp(widget.entry, 'owner_package_name'); + _ownerPackage = await metadataService.getContentResolverProp(widget.entry, 'owner_package_name'); _loadedUri.value = entry.uri; } else { _ownerPackage = null; diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index d5310481f..becd341bc 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -98,7 +98,7 @@ class _InfoRowGroupState extends State { if (linkHandlers?.containsKey(key) == true) { final handler = linkHandlers[key]; - value = handler.linkText; + value = handler.linkText(context); // open link on tap recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); style = InfoRowGroup.linkStyle; @@ -149,7 +149,7 @@ class _InfoRowGroupState extends State { } class InfoLinkHandler { - final String linkText; + final String Function(BuildContext context) linkText; final void Function(BuildContext context) onTap; const InfoLinkHandler({ diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 34e2aebe2..7ee11eb9d 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,6 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/app_bar_title.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:flutter/material.dart'; @@ -23,17 +24,17 @@ class InfoAppBar extends StatelessWidget { key: Key('back-button'), icon: Icon(AIcons.goUp), onPressed: onBackPressed, - tooltip: 'Back to viewer', + tooltip: context.l10n.viewerInfoBackToViewerTooltip, ), - title: TappableAppBarTitle( + title: InteractiveAppBarTitle( onTap: () => _goToSearch(context), - child: Text('Info'), + child: Text(context.l10n.viewerInfoPageTitle), ), actions: [ IconButton( icon: Icon(AIcons.search), onPressed: () => _goToSearch(context), - tooltip: 'Search', + tooltip: MaterialLocalizations.of(context).searchFieldLabel, ), ], titleSpacing: 0, @@ -45,6 +46,7 @@ class InfoAppBar extends StatelessWidget { showSearch( context: context, delegate: InfoSearchDelegate( + searchFieldLabel: context.l10n.viewerInfoSearchFieldLabel, entry: entry, metadataNotifier: metadataNotifier, ), diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index f8c94fe1b..a594ad2be 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -1,7 +1,8 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; @@ -15,19 +16,12 @@ class InfoSearchDelegate extends SearchDelegate { Map get metadata => metadataNotifier.value; - static const suggestions = { - 'Date & time': 'date or time or when -timer -uptime -exposure -timeline', - 'Description': 'abstract or description or comment or textual', - 'Dimensions': 'width or height or dimension or framesize or imagelength', - 'Resolution': 'resolution', - 'Rights': 'rights or copyright or artist or creator or by-line or credit -tool', - }; - InfoSearchDelegate({ + @required String searchFieldLabel, @required this.entry, @required this.metadataNotifier, }) : super( - searchFieldLabel: 'Search metadata', + searchFieldLabel: searchFieldLabel, ); @override @@ -57,23 +51,33 @@ class InfoSearchDelegate extends SearchDelegate { query = ''; showSuggestions(context); }, - tooltip: 'Clear', + tooltip: context.l10n.clearTooltip, ), ]; } @override - Widget buildSuggestions(BuildContext context) => ListView( - children: suggestions.entries - .map((kv) => ListTile( - title: Text(kv.key), - onTap: () { - query = kv.value; - showResults(context); - }, - )) - .toList(), - ); + Widget buildSuggestions(BuildContext context) { + final l10n = context.l10n; + final suggestions = { + l10n.viewerInfoSearchSuggestionDate: 'date or time or when -timer -uptime -exposure -timeline', + l10n.viewerInfoSearchSuggestionDescription: 'abstract or description or comment or textual', + l10n.viewerInfoSearchSuggestionDimensions: 'width or height or dimension or framesize or imagelength', + l10n.viewerInfoSearchSuggestionResolution: 'resolution', + l10n.viewerInfoSearchSuggestionRights: 'rights or copyright or artist or creator or by-line or credit -tool', + }; + return ListView( + children: suggestions.entries + .map((kv) => ListTile( + title: Text(kv.key), + onTap: () { + query = kv.value; + showResults(context); + }, + )) + .toList(), + ); + } @override Widget buildResults(BuildContext context) { @@ -107,22 +111,24 @@ class InfoSearchDelegate extends SearchDelegate { showPrefixChildren: false, )) .toList(); - return tiles.isEmpty - ? EmptyContent( - icon: AIcons.info, - text: 'No matching keys', - ) - : NotificationListener( - onNotification: (notification) { - _openTempEntry(context, notification.entry); - return true; - }, - child: ListView.builder( - padding: EdgeInsets.all(8), - itemBuilder: (context, index) => tiles[index], - itemCount: tiles.length, + return SafeArea( + child: tiles.isEmpty + ? EmptyContent( + icon: AIcons.info, + text: context.l10n.viewerInfoSearchEmpty, + ) + : NotificationListener( + onNotification: (notification) { + _openTempEntry(context, notification.entry); + return true; + }, + child: ListView.builder( + padding: EdgeInsets.all(8), + itemBuilder: (context, index) => tiles[index], + itemCount: tiles.length, + ), ), - ); + ); } void _openTempEntry(BuildContext context, AvesEntry tempEntry) { diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 63752c3da..9682d53b0 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -1,12 +1,13 @@ -import 'package:aves/model/availability.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/maps/common.dart'; @@ -195,9 +196,10 @@ class _AddressInfoGroupState extends State<_AddressInfoGroup> { future: _addressLineLoader, builder: (context, snapshot) { final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : null; + final l10n = context.l10n; return InfoRowGroup({ - 'Coordinates': settings.coordinateFormat.format(entry.latLng), - if (address?.isNotEmpty == true) 'Address': address, + l10n.viewerInfoLabelCoordinates: settings.coordinateFormat.format(entry.latLng), + if (address?.isNotEmpty == true) l10n.viewerInfoLabelAddress: address, }); }, ); diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/viewer/info/maps/common.dart index 6cf73e190..3a7ccc699 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/viewer/info/maps/common.dart @@ -1,9 +1,11 @@ -import 'package:aves/model/availability.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; @@ -68,7 +70,7 @@ class MapButtonPanel extends StatelessWidget { onPressed: () => AndroidAppService.openMap(geoUri).then((success) { if (!success) showNoMatchingAppDialog(context); }), - tooltip: 'Show on map…', + tooltip: context.l10n.entryActionOpenMap, ), SizedBox(height: padding), MapOverlayButton( @@ -83,8 +85,8 @@ class MapButtonPanel extends StatelessWidget { builder: (context) { return AvesSelectionDialog( initialValue: initialStyle, - options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.name))), - title: 'Map Style', + options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))), + title: context.l10n.viewerInfoMapStyleTitle, ); }, ); @@ -95,19 +97,19 @@ class MapButtonPanel extends StatelessWidget { MapStyleChangedNotification().dispatch(context); } }, - tooltip: 'Style map…', + tooltip: context.l10n.viewerInfoMapStyleTooltip, ), Spacer(), MapOverlayButton( icon: AIcons.zoomIn, onPressed: () => zoomBy(1), - tooltip: 'Zoom in', + tooltip: context.l10n.viewerInfoMapZoomInTooltip, ), SizedBox(height: padding), MapOverlayButton( icon: AIcons.zoomOut, onPressed: () => zoomBy(-1), - tooltip: 'Zoom out', + tooltip: context.l10n.viewerInfoMapZoomOutTooltip, ), ], ), diff --git a/lib/widgets/viewer/info/maps/google_map.dart b/lib/widgets/viewer/info/maps/google_map.dart index 25781b1d1..c4ebfc55a 100644 --- a/lib/widgets/viewer/info/maps/google_map.dart +++ b/lib/widgets/viewer/info/maps/google_map.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:aves/model/settings/map_style.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/viewer/info/maps/common.dart'; import 'package:aves/widgets/viewer/info/maps/marker.dart'; diff --git a/lib/widgets/viewer/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart index dfbc8c00b..0ac426413 100644 --- a/lib/widgets/viewer/info/maps/leaflet_map.dart +++ b/lib/widgets/viewer/info/maps/leaflet_map.dart @@ -1,5 +1,7 @@ -import 'package:aves/model/settings/map_style.dart'; +import 'package:provider/provider.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/maps/common.dart'; import 'package:aves/widgets/viewer/info/maps/scale_layer.dart'; @@ -71,6 +73,7 @@ class _EntryLeafletMapState extends State with AutomaticKeepAli zoom: widget.initialZoom, interactive: false, ), + mapController: _mapController, children: [ _buildMapLayer(), ScaleLayerWidget( @@ -90,7 +93,6 @@ class _EntryLeafletMapState extends State with AutomaticKeepAli ), ), ], - mapController: _mapController, ); } @@ -110,10 +112,10 @@ class _EntryLeafletMapState extends State with AutomaticKeepAli Widget _buildAttribution() { switch (widget.style) { case EntryMapStyle.osmHot: - return _buildAttributionMarkdown('Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, tiles by [HOT](https://www.hotosm.org/) hosted by [OSM France](https://openstreetmap.fr/)'); + return _buildAttributionMarkdown(context.l10n.mapAttributionOsmHot); case EntryMapStyle.stamenToner: case EntryMapStyle.stamenWatercolor: - return _buildAttributionMarkdown('Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)'); + return _buildAttributionMarkdown(context.l10n.mapAttributionStamen); default: return SizedBox.shrink(); } @@ -169,7 +171,7 @@ class OSMHotLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', subdomains: ['a', 'b', 'c'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } @@ -182,7 +184,7 @@ class StamenTonerLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', subdomains: ['a', 'b', 'c', 'd'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } @@ -195,7 +197,7 @@ class StamenWatercolorLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', subdomains: ['a', 'b', 'c', 'd'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart index c40806c22..ef019964d 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/collection/thumbnail/vector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; class ImageMarker extends StatelessWidget { final AvesEntry entry; @@ -153,7 +154,7 @@ class _MarkerGeneratorWidgetState extends State { @override Widget build(BuildContext context) { return Transform.translate( - offset: Offset(MediaQuery.of(context).size.width, 0), + offset: Offset(context.select((mq) => mq.size.width), 0), child: Material( type: MaterialType.transparency, child: Stack( @@ -171,7 +172,7 @@ class _MarkerGeneratorWidgetState extends State { } Future> _getBitmaps(BuildContext context) async { - final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final pixelRatio = context.read().devicePixelRatio; return Future.wait(_globalKeys.map((key) async { RenderRepaintBoundary boundary = key.currentContext.findRenderObject(); final image = await boundary.toImage(pixelRatio: pixelRatio); diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 3bc2f74b6..ad1319723 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -7,6 +7,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart'; @@ -89,7 +90,7 @@ class MetadataDirTile extends StatelessWidget { static Map getSvgLinkHandlers(SplayTreeMap tags) { return { 'Metadata': InfoLinkHandler( - linkText: 'View XML', + linkText: (context) => context.l10n.viewerInfoViewXmlLinkText, onTap: (context) { Navigator.push( context, diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index d148be209..9a866b422 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; @@ -138,7 +138,7 @@ class _MetadataSectionSliverState extends State with Auto if (entry == null) return; if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { - final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; + final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataService.getAllMetadata(entry)) ?? {}; final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; diff --git a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart index 646bf9736..6d6bc926c 100644 --- a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart @@ -2,8 +2,9 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:aves/model/entry.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; enum MetadataThumbnailSource { embedded, exif } @@ -33,10 +34,10 @@ class _MetadataThumbnailsState extends State { super.initState(); switch (widget.source) { case MetadataThumbnailSource.embedded: - _loader = MetadataService.getEmbeddedPictures(uri); + _loader = metadataService.getEmbeddedPictures(uri); break; case MetadataThumbnailSource.exif: - _loader = MetadataService.getExifThumbnails(entry); + _loader = metadataService.getExifThumbnails(entry); break; } } @@ -47,7 +48,6 @@ class _MetadataThumbnailsState extends State { future: _loader, builder: (context, snapshot) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return Container( alignment: AlignmentDirectional.topStart, padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4), @@ -55,7 +55,7 @@ class _MetadataThumbnailsState extends State { children: snapshot.data.map((bytes) { return Image.memory( bytes, - scale: devicePixelRatio, + scale: context.select((mq) => mq.devicePixelRatio), ); }).toList(), ), diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index cccb66342..718ce3f1a 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -1,3 +1,4 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:tuple/tuple.dart'; @@ -18,7 +19,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace { ? MapEntry( dataProp.displayKey, InfoLinkHandler( - linkText: 'Open', + linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification( propPath: dataProp.path, mimeType: mimeProp.value, diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart index 63286f574..321452173 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/mwg.dart @@ -26,15 +26,15 @@ class XmpMgwRegionsNamespace extends XmpNamespace { @override List buildFromExtractedData() => [ - if (dimensions.isNotEmpty) - XmpStructCard( - title: 'Applied To Dimensions', - struct: dimensions, - ), - if (regionList.isNotEmpty) - XmpStructArrayCard( - title: 'Region', - structByIndex: regionList, - ), - ]; + if (dimensions.isNotEmpty) + XmpStructCard( + title: 'Applied To Dimensions', + struct: dimensions, + ), + if (regionList.isNotEmpty) + XmpStructArrayCard( + title: 'Region', + structByIndex: regionList, + ), + ]; } diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 70a57de2c..9ddd5bb91 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -1,4 +1,5 @@ import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_structs.dart'; @@ -31,7 +32,7 @@ class XmpBasicNamespace extends XmpNamespace { return { if (struct.containsKey(thumbnailDataDisplayKey)) thumbnailDataDisplayKey: InfoLinkHandler( - linkText: 'Open', + linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification( propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', mimeType: MimeTypes.jpeg, diff --git a/lib/widgets/viewer/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart index 8bd946184..6060f46bf 100644 --- a/lib/widgets/viewer/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -4,6 +4,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/basic/multi_cross_fader.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:flutter/material.dart'; @@ -68,13 +69,13 @@ class _XmpStructArrayCardState extends State { visualDensity: VisualDensity.compact, icon: Icon(AIcons.previous), onPressed: _index > 0 ? () => setIndex(_index - 1) : null, - tooltip: 'Previous', + tooltip: context.l10n.previousTooltip, ), IconButton( visualDensity: VisualDensity.compact, icon: Icon(AIcons.next), onPressed: _index < structs.length - 1 ? () => setIndex(_index + 1) : null, - tooltip: 'Next', + tooltip: context.l10n.nextTooltip, ), ], ), diff --git a/lib/widgets/viewer/info/metadata/xmp_tile.dart b/lib/widgets/viewer/info/metadata/xmp_tile.dart index 94ccc00f7..141cd9c9c 100644 --- a/lib/widgets/viewer/info/metadata/xmp_tile.dart +++ b/lib/widgets/viewer/info/metadata/xmp_tile.dart @@ -4,8 +4,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/xmp.dart'; import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; @@ -104,9 +105,9 @@ class _XmpDirTileState extends State with FeedbackMixin { } Future _openEmbeddedData(String propPath, String propMimeType) async { - final fields = await MetadataService.extractXmpDataProp(entry, propPath, propMimeType); + final fields = await metadataService.extractXmpDataProp(entry, propPath, propMimeType); if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { - showFeedback(context, 'Failed'); + showFeedback(context, context.l10n.viewerInfoOpenEmbeddedFailureFeedback); return; } diff --git a/lib/widgets/viewer/multipage.dart b/lib/widgets/viewer/multipage.dart index 91de82859..c599f09de 100644 --- a/lib/widgets/viewer/multipage.dart +++ b/lib/widgets/viewer/multipage.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/multipage.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,7 +11,7 @@ class MultiPageController extends ChangeNotifier { final ValueNotifier pageNotifier = ValueNotifier(null); MultiPageController(AvesEntry entry) { - info = MetadataService.getMultiPageInfo(entry).then((value) { + info = metadataService.getMultiPageInfo(entry).then((value) { pageNotifier.value = value.defaultPage.index; return value; }); diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 03d1f0567..a36837652 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -5,10 +5,11 @@ import 'package:aves/model/metadata.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; @@ -68,7 +69,7 @@ class _ViewerBottomOverlayState extends State { } void _initDetailLoader() { - _detailLoader = MetadataService.getOverlayMetadata(entry); + _detailLoader = metadataService.getOverlayMetadata(entry); } @override @@ -384,9 +385,14 @@ class _DateRow extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = context.l10n.localeName; final date = entry.bestDate; - final dateText = date != null ? '${DateFormat.yMMMd().format(date)} • ${DateFormat.Hm().format(date)}' : Constants.overlayUnknown; - final resolutionText = entry.isSvg ? entry.aspectRatioText : entry.isSized ? entry.resolutionText : ''; + final dateText = date != null ? '${DateFormat.yMMMd(locale).format(date)} • ${DateFormat.Hm(locale).format(date)}' : Constants.overlayUnknown; + final resolutionText = entry.isSvg + ? entry.aspectRatioText + : entry.isSized + ? entry.resolutionText + : ''; return Row( children: [ diff --git a/lib/widgets/viewer/overlay/common.dart b/lib/widgets/viewer/overlay/common.dart index 23ad79666..0ef41c77a 100644 --- a/lib/widgets/viewer/overlay/common.dart +++ b/lib/widgets/viewer/overlay/common.dart @@ -38,13 +38,13 @@ class OverlayButton extends StatelessWidget { class OverlayTextButton extends StatelessWidget { final Animation scale; - final String text; + final String buttonLabel; final VoidCallback onPressed; const OverlayTextButton({ Key key, @required this.scale, - @required this.text, + @required this.buttonLabel, this.onPressed, }) : assert(scale != null), super(key: key); @@ -71,7 +71,7 @@ class OverlayTextButton extends StatelessWidget { )), // shape: MaterialStateProperty.all(CircleBorder()), ), - child: Text(text.toUpperCase()), + child: Text(buttonLabel), ), ), ); diff --git a/lib/widgets/viewer/overlay/multipage.dart b/lib/widgets/viewer/overlay/multipage.dart index cc5c5ece5..b40adda34 100644 --- a/lib/widgets/viewer/overlay/multipage.dart +++ b/lib/widgets/viewer/overlay/multipage.dart @@ -4,6 +4,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; +import 'package:aves/widgets/collection/thumbnail/theme.dart'; import 'package:aves/widgets/viewer/multipage.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -80,66 +81,69 @@ class _MultiPageOverlayState extends State { final horizontalMargin = SizedBox(width: marginWidth); final separator = SizedBox(width: separatorWidth); - return FutureBuilder( - future: controller.info, - builder: (context, snapshot) { - final multiPageInfo = snapshot.data; - if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); - if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); - return SizedBox( - height: extent, - child: ListView.separated( - key: ValueKey(mainEntry), - scrollDirection: Axis.horizontal, - controller: _scrollController, - // default padding in scroll direction matches `MediaQuery.viewPadding`, - // but we already accommodate for it, so make sure horizontal padding is 0 - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; - final page = index - 1; - final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); + return ThumbnailTheme( + extent: extent, + child: FutureBuilder( + future: controller.info, + builder: (context, snapshot) { + final multiPageInfo = snapshot.data; + if ((multiPageInfo?.pageCount ?? 0) <= 1) return SizedBox(); + if (multiPageInfo.uri != mainEntry.uri) return SizedBox(); + return SizedBox( + height: extent, + child: ListView.separated( + key: ValueKey(mainEntry), + scrollDirection: Axis.horizontal, + controller: _scrollController, + // default padding in scroll direction matches `MediaQuery.viewPadding`, + // but we already accommodate for it, so make sure horizontal padding is 0 + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + if (index == 0 || index == multiPageInfo.pageCount + 1) return horizontalMargin; + final page = index - 1; + final pageEntry = mainEntry.getPageEntry(multiPageInfo.getByIndex(page)); - return Stack( - children: [ - GestureDetector( - onTap: () async { - _syncScroll = false; - controller.page = page; - await _scrollController.animateTo( - pageToScrollOffset(page), - duration: Durations.viewerOverlayPageScrollAnimation, - curve: Curves.easeOutCubic, - ); - _syncScroll = true; - }, - child: DecoratedThumbnail( - entry: pageEntry, - extent: extent, - // the retrieval task queue can pile up for thumbnails of heavy pages - // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) - // so we cancel these requests when possible - cancellableNotifier: _cancellableNotifier, - selectable: false, - highlightable: false, + return Stack( + children: [ + GestureDetector( + onTap: () async { + _syncScroll = false; + controller.page = page; + await _scrollController.animateTo( + pageToScrollOffset(page), + duration: Durations.viewerOverlayPageScrollAnimation, + curve: Curves.easeOutCubic, + ); + _syncScroll = true; + }, + child: DecoratedThumbnail( + entry: pageEntry, + extent: extent, + // the retrieval task queue can pile up for thumbnails of heavy pages + // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) + // so we cancel these requests when possible + cancellableNotifier: _cancellableNotifier, + selectable: false, + highlightable: false, + ), ), - ), - IgnorePointer( - child: AnimatedContainer( - color: controller.page == page ? Colors.transparent : Colors.black45, - width: extent, - height: extent, - duration: Durations.viewerOverlayPageShadeAnimation, - ), - ) - ], - ); - }, - separatorBuilder: (context, index) => separator, - itemCount: multiPageInfo.pageCount + 2, - ), - ); - }, + IgnorePointer( + child: AnimatedContainer( + color: controller.page == page ? Colors.transparent : Colors.black45, + width: extent, + height: extent, + duration: Durations.viewerOverlayPageShadeAnimation, + ), + ) + ], + ); + }, + separatorBuilder: (context, index) => separator, + itemCount: multiPageInfo.pageCount + 2, + ), + ); + }, + ), ); } diff --git a/lib/widgets/viewer/overlay/panorama.dart b/lib/widgets/viewer/overlay/panorama.dart index 0d142edeb..4998f86c8 100644 --- a/lib/widgets/viewer/overlay/panorama.dart +++ b/lib/widgets/viewer/overlay/panorama.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/panorama_page.dart'; import 'package:flutter/material.dart'; @@ -22,19 +23,21 @@ class PanoramaOverlay extends StatelessWidget { Spacer(), OverlayTextButton( scale: scale, - text: 'Open Panorama', + buttonLabel: context.l10n.viewerOpenPanoramaButtonLabel, onPressed: () async { - final info = await MetadataService.getPanoramaInfo(entry); - unawaited(Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: PanoramaPage.routeName), - builder: (context) => PanoramaPage( - entry: entry, - info: info, + final info = await metadataService.getPanoramaInfo(entry); + if (info != null) { + unawaited(Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: PanoramaPage.routeName), + builder: (context) => PanoramaPage( + entry: entry, + info: info, + ), ), - ), - )); + )); + } }, ) ], diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 44fc7a3f1..8a4b4558f 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -2,11 +2,12 @@ import 'dart:math'; import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/favourite_repo.dart'; +import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/sweeper.dart'; import 'package:aves/widgets/viewer/multipage.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; @@ -160,19 +161,19 @@ class _TopOverlayRow extends StatelessWidget { child: Navigator.canPop(context) ? BackButton() : CloseButton(), ), Spacer(), - ...quickActions.map(_buildOverlayButton), + ...quickActions.map((action) => _buildOverlayButton(context, action)), OverlayButton( scale: scale, child: PopupMenuButton( key: Key('entry-menu-button'), itemBuilder: (context) => [ - ...inAppActions.map(_buildPopupMenuItem), - if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(), + ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), + if (entry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), PopupMenuDivider(), - ...externalAppActions.map(_buildPopupMenuItem), + ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), if (kDebugMode) ...[ PopupMenuDivider(), - _buildPopupMenuItem(EntryAction.debug), + _buildPopupMenuItem(context, EntryAction.debug), ] ], onSelected: (action) { @@ -185,7 +186,7 @@ class _TopOverlayRow extends StatelessWidget { ); } - Widget _buildOverlayButton(EntryAction action) { + Widget _buildOverlayButton(BuildContext context, EntryAction action) { Widget child; void onPressed() => onActionSelected(action); switch (action) { @@ -208,7 +209,7 @@ class _TopOverlayRow extends StatelessWidget { child = IconButton( icon: Icon(action.getIcon()), onPressed: onPressed, - tooltip: action.getText(), + tooltip: action.getText(context), ); break; case EntryAction.openMap: @@ -229,7 +230,7 @@ class _TopOverlayRow extends StatelessWidget { : SizedBox.shrink(); } - PopupMenuEntry _buildPopupMenuItem(EntryAction action) { + PopupMenuEntry _buildPopupMenuItem(BuildContext context, EntryAction action) { Widget child; switch (action) { // in app actions @@ -250,14 +251,14 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.share: case EntryAction.viewSource: case EntryAction.debug: - child = MenuRow(text: action.getText(), icon: action.getIcon()); + child = MenuRow(text: action.getText(context), icon: action.getIcon()); break; // external app actions case EntryAction.edit: case EntryAction.open: case EntryAction.setAs: case EntryAction.openMap: - child = Text(action.getText()); + child = Text(action.getText(context)); break; } return PopupMenuItem( @@ -266,7 +267,7 @@ class _TopOverlayRow extends StatelessWidget { ); } - PopupMenuItem _buildRotateAndFlipMenuItems() { + PopupMenuItem _buildRotateAndFlipMenuItems(BuildContext context) { Widget buildDivider() => SizedBox( height: 16, child: VerticalDivider( @@ -279,7 +280,7 @@ class _TopOverlayRow extends StatelessWidget { child: PopupMenuItem( value: action, child: Tooltip( - message: action.getText(), + message: action.getText(context), child: Center(child: Icon(action.getIcon())), ), ), @@ -322,7 +323,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { @override void initState() { super.initState(); - favourites.changeNotifier.addListener(_onChanged); + favourites.addListener(_onChanged); _onChanged(); } @@ -334,7 +335,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { @override void dispose() { - favourites.changeNotifier.removeListener(_onChanged); + favourites.removeListener(_onChanged); super.dispose(); } @@ -346,11 +347,11 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { if (widget.isMenuItem) { return isFavourite ? MenuRow( - text: 'Remove from favourites', + text: context.l10n.entryActionRemoveFavourite, icon: AIcons.favouriteActive, ) : MenuRow( - text: 'Add to favourites', + text: context.l10n.entryActionAddFavourite, icon: AIcons.favourite, ); } @@ -360,7 +361,7 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { IconButton( icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite), onPressed: widget.onPressed, - tooltip: isFavourite ? 'Remove from favourites' : 'Add to favourites', + tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, ), Sweeper( key: ValueKey(widget.entry), diff --git a/lib/widgets/viewer/overlay/video.dart b/lib/widgets/viewer/overlay/video.dart index 59c1cd9d5..78bed2dfb 100644 --- a/lib/widgets/viewer/overlay/video.dart +++ b/lib/widgets/viewer/overlay/video.dart @@ -5,6 +5,7 @@ import 'package:aves/services/android_app_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/time_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; @@ -112,7 +113,7 @@ class _VideoControlOverlayState extends State with SingleTi child: IconButton( icon: Icon(AIcons.openOutside), onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), - tooltip: 'Open', + tooltip: context.l10n.viewerOpenTooltip, ), ), ] @@ -129,7 +130,7 @@ class _VideoControlOverlayState extends State with SingleTi progress: _playPauseAnimation, ), onPressed: _playPause, - tooltip: isPlaying ? 'Pause' : 'Play', + tooltip: isPlaying ? context.l10n.viewerPauseTooltip : context.l10n.viewerPlayTooltip, ), ), ], diff --git a/lib/widgets/viewer/panorama_page.dart b/lib/widgets/viewer/panorama_page.dart index 979971d58..0c4a51653 100644 --- a/lib/widgets/viewer/panorama_page.dart +++ b/lib/widgets/viewer/panorama_page.dart @@ -1,8 +1,9 @@ -import 'package:aves/model/entry_images.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_images.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/foundation.dart'; @@ -100,7 +101,7 @@ class _PanoramaPageState extends State { return IconButton( icon: Icon(sensorControl == SensorControl.None ? AIcons.sensorControl : AIcons.sensorControlOff), onPressed: _toggleSensor, - tooltip: sensorControl == SensorControl.None ? 'Enable sensor control' : 'Disable sensor control', + tooltip: sensorControl == SensorControl.None ? context.l10n.panoramaEnableSensorControl : context.l10n.panoramaDisableSensorControl, ); }), ), diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index dee300e6f..f8e35323c 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -3,9 +3,9 @@ import 'dart:convert'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; -import 'package:aves/services/image_file_service.dart'; -import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; import 'package:pdf/widgets.dart' as pdf; import 'package:pedantic/pedantic.dart'; @@ -17,12 +17,12 @@ class EntryPrinter with FeedbackMixin { EntryPrinter(this.entry); Future print(BuildContext context) async { - final documentName = entry.bestTitle ?? 'Aves'; + final documentName = entry.bestTitle ?? context.l10n.appName; final doc = pdf.Document(title: documentName); final pages = await _buildPages(context); if (pages.isNotEmpty) { - pages.forEach(doc.addPage); // Page + pages.forEach(doc.addPage); unawaited(Printing.layoutPdf( onLayout: (format) => doc.save(), name: documentName, @@ -48,7 +48,7 @@ class EntryPrinter with FeedbackMixin { } if (entry.isMultipage) { - final multiPageInfo = await MetadataService.getMultiPageInfo(entry); + final multiPageInfo = await metadataService.getMultiPageInfo(entry); if (multiPageInfo.pageCount > 1) { final streamController = StreamController.broadcast(); showOpReport( @@ -72,7 +72,7 @@ class EntryPrinter with FeedbackMixin { Future _buildPageImage(AvesEntry entry) async { if (entry.isSvg) { - final bytes = await ImageFileService.getSvg(entry.uri, entry.mimeType); + final bytes = await imageFileService.getSvg(entry.uri, entry.mimeType); if (bytes != null && bytes.isNotEmpty) { return pdf.SvgImage(svg: utf8.decode(bytes)); } diff --git a/lib/widgets/viewer/source_viewer_page.dart b/lib/widgets/viewer/source_viewer_page.dart index 75bd97973..62b40b044 100644 --- a/lib/widgets/viewer/source_viewer_page.dart +++ b/lib/widgets/viewer/source_viewer_page.dart @@ -1,4 +1,5 @@ import 'package:aves/widgets/common/aves_highlight.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; import 'package:flutter_highlight/themes/darcula.dart'; @@ -28,7 +29,7 @@ class _SourceViewerPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Source'), + title: Text(context.l10n.sourceViewerPageTitle), ), body: SafeArea( child: FutureBuilder( diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index cf212cfa1..784956cb2 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -4,6 +4,7 @@ import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; diff --git a/lib/widgets/viewer/visual/error.dart b/lib/widgets/viewer/visual/error.dart index f16192aed..34d3cf97b 100644 --- a/lib/widgets/viewer/visual/error.dart +++ b/lib/widgets/viewer/visual/error.dart @@ -2,7 +2,8 @@ import 'dart:io'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -45,7 +46,7 @@ class _ErrorViewState extends State { final exists = snapshot.data; return EmptyContent( icon: AIcons.error, - text: exists ? 'Oops!' : 'The file no longer exists.', + text: exists ? context.l10n.viewerErrorUnknown : context.l10n.viewerErrorDoesNotExist, alignment: Alignment.center, ); }), diff --git a/lib/widgets/viewer/visual/raster.dart b/lib/widgets/viewer/visual/raster.dart index 72098fb99..77bf923b5 100644 --- a/lib/widgets/viewer/visual/raster.dart +++ b/lib/widgets/viewer/visual/raster.dart @@ -4,6 +4,7 @@ import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 9567d0f6b..a2c27dee0 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -1,13 +1,16 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/labeled_checkbox.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; class WelcomePage extends StatefulWidget { @@ -29,12 +32,13 @@ class _WelcomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Container( - alignment: Alignment.center, - padding: EdgeInsets.all(16.0), - child: FutureBuilder( + return MediaQueryDataProvider( + child: Scaffold( + body: SafeArea( + child: Container( + alignment: Alignment.center, + padding: EdgeInsets.all(16.0), + child: FutureBuilder( future: _termsLoader, builder: (context, snapshot) { if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); @@ -58,7 +62,9 @@ class _WelcomePageState extends State { ], ), ); - }), + }, + ), + ), ), ), ); @@ -66,11 +72,11 @@ class _WelcomePageState extends State { List _buildTop(BuildContext context) { final message = Text( - 'Welcome to Aves', + context.l10n.welcomeMessage, style: Theme.of(context).textTheme.headline5, ); return [ - ...(MediaQuery.of(context).orientation == Orientation.portrait + ...(context.select((mq) => mq.orientation) == Orientation.portrait ? [ AvesLogo(size: 64), SizedBox(height: 16), @@ -97,20 +103,19 @@ class _WelcomePageState extends State { LabeledCheckbox( value: settings.isCrashlyticsEnabled, onChanged: (v) => setState(() => settings.isCrashlyticsEnabled = v), - text: 'Allow anonymous analytics and crash reporting', + text: context.l10n.welcomeAnalyticsToggle, ), LabeledCheckbox( key: Key('agree-checkbox'), value: _hasAcceptedTerms, onChanged: (v) => setState(() => _hasAcceptedTerms = v), - text: 'I agree to the terms and conditions', + text: context.l10n.welcomeTermsToggle, ), ], ); final button = ElevatedButton( key: Key('continue-button'), - child: Text('Continue'), onPressed: _hasAcceptedTerms ? () { settings.hasAcceptedTerms = true; @@ -123,9 +128,10 @@ class _WelcomePageState extends State { ); } : null, + child: Text(context.l10n.continueButtonLabel), ); - return MediaQuery.of(context).orientation == Orientation.portrait + return context.select((mq) => mq.orientation) == Orientation.portrait ? [ checkboxes, button, diff --git a/pubspec.lock b/pubspec.lock index 3075a429c..4fb30fd84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "12.0.0" + version: "17.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "0.40.6" + version: "1.1.0" ansicolor: dependency: transitive description: @@ -28,112 +28,105 @@ packages: name: archive url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "3.1.2" args: dependency: transitive description: name: args url: "https://pub.dartlang.org" source: hosted - version: "1.6.0" + version: "2.0.0" async: dependency: transitive description: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.5.0-nullsafety.1" + version: "2.5.0" barcode: dependency: transitive description: name: barcode url: "https://pub.dartlang.org" source: hosted - version: "1.17.1" + version: "2.1.0" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" characters: dependency: transitive description: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.3" + version: "1.1.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" charts_common: dependency: transitive description: name: charts_common url: "https://pub.dartlang.org" source: hosted - version: "0.9.0" + version: "0.10.0" charts_flutter: dependency: "direct main" description: name: charts_flutter url: "https://pub.dartlang.org" source: hosted - version: "0.9.0" + version: "0.10.0" cli_util: dependency: transitive description: name: cli_util url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.3.0" clock: dependency: transitive description: name: clock url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" collection: dependency: "direct main" description: name: collection url: "https://pub.dartlang.org" source: hosted - version: "1.15.0-nullsafety.3" + version: "1.15.0" connectivity: dependency: "direct main" description: name: connectivity url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" - connectivity_for_web: - dependency: transitive - description: - name: connectivity_for_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.1+4" + version: "3.0.2" connectivity_macos: dependency: transitive description: name: connectivity_macos url: "https://pub.dartlang.org" source: hosted - version: "0.1.0+7" + version: "0.2.0" connectivity_platform_interface: dependency: transitive description: name: connectivity_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.6" + version: "2.0.1" console_log_handler: dependency: transitive description: @@ -147,28 +140,28 @@ packages: name: convert url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "3.0.0" country_code: dependency: "direct main" description: name: country_code url: "https://pub.dartlang.org" source: hosted - version: "0.1.1" + version: "1.0.0" coverage: dependency: transitive description: name: coverage url: "https://pub.dartlang.org" source: hosted - version: "0.14.2" + version: "0.15.2" crypto: dependency: transitive description: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.5" + version: "3.0.0" decorated_icon: dependency: "direct main" description: @@ -182,107 +175,100 @@ packages: name: event_bus url: "https://pub.dartlang.org" source: hosted - version: "1.1.1" + version: "2.0.0" expansion_tile_card: dependency: "direct main" description: path: "." ref: HEAD - resolved-ref: "51fe2b12588356fade82ce65daef5482beed54e7" + resolved-ref: b80a0b322622f28bd783cccef3663aa7aa806e01 url: "git://github.com/deckerst/expansion_tile_card.git" source: git - version: "1.0.3" + version: "2.0.0" fake_async: dependency: transitive description: name: fake_async url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" ffi: dependency: transitive description: name: ffi url: "https://pub.dartlang.org" source: hosted - version: "0.1.3" + version: "1.0.0" file: dependency: transitive description: name: file url: "https://pub.dartlang.org" source: hosted - version: "6.0.0-nullsafety.2" + version: "6.1.0" firebase: dependency: transitive description: name: firebase url: "https://pub.dartlang.org" source: hosted - version: "7.3.3" + version: "9.0.0" firebase_analytics: dependency: "direct main" description: name: firebase_analytics url: "https://pub.dartlang.org" source: hosted - version: "7.0.1" + version: "7.1.1" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.1.0" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.1" + version: "0.2.0+1" firebase_core: dependency: "direct main" description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "0.7.0" + version: "1.0.1" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "4.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.1+3" + version: "1.0.1" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics url: "https://pub.dartlang.org" source: hosted - version: "0.4.0+1" + version: "1.0.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.1.6" - flushbar: - dependency: "direct main" - description: - name: flushbar - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.4" + version: "2.0.0" flutter: dependency: "direct main" description: flutter @@ -294,7 +280,7 @@ packages: name: flutter_cube url: "https://pub.dartlang.org" source: hosted - version: "0.0.6" + version: "0.1.1" flutter_driver: dependency: "direct dev" description: flutter @@ -306,13 +292,13 @@ packages: name: flutter_highlight url: "https://pub.dartlang.org" source: hosted - version: "0.6.0" + version: "0.7.0" flutter_ijkplayer: dependency: "direct main" description: path: "." ref: HEAD - resolved-ref: c1b7f25e2a3bc67ab7b30561af49a62ae9a8c409 + resolved-ref: d4e079404ba8e4f82a7e053ffdc47af787a61c3b url: "git://github.com/deckerst/flutter_ijkplayer.git" source: git version: "0.3.7" @@ -323,6 +309,18 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_localized_locales: + dependency: "direct main" + description: + name: flutter_localized_locales + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" flutter_map: dependency: "direct main" description: @@ -336,28 +334,28 @@ packages: name: flutter_markdown url: "https://pub.dartlang.org" source: hosted - version: "0.5.2" + version: "0.6.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle url: "https://pub.dartlang.org" source: hosted - version: "1.0.11" + version: "2.0.0" flutter_staggered_animations: dependency: "direct main" description: name: flutter_staggered_animations url: "https://pub.dartlang.org" source: hosted - version: "0.1.2" + version: "1.0.0" flutter_svg: dependency: "direct main" description: name: flutter_svg url: "https://pub.dartlang.org" source: hosted - version: "0.19.1" + version: "0.21.0-nullsafety.0" flutter_test: dependency: "direct dev" description: flutter @@ -373,118 +371,111 @@ packages: description: flutter source: sdk version: "0.0.0" - geocoder: + get_it: dependency: "direct main" description: - name: geocoder + name: get_it url: "https://pub.dartlang.org" source: hosted - version: "0.2.1" + version: "6.0.0" github: dependency: "direct main" description: name: github url: "https://pub.dartlang.org" source: hosted - version: "7.0.4" + version: "8.0.1" glob: dependency: transitive description: name: glob url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "2.0.0" google_api_availability: dependency: "direct main" description: name: google_api_availability url: "https://pub.dartlang.org" source: hosted - version: "2.0.4" + version: "3.0.0" google_maps_flutter: dependency: "direct main" description: name: google_maps_flutter url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "2.0.1" google_maps_flutter_platform_interface: dependency: transitive description: name: google_maps_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "2.0.1" highlight: dependency: transitive description: name: highlight url: "https://pub.dartlang.org" source: hosted - version: "0.6.0" + version: "0.7.0" http: dependency: transitive description: name: http url: "https://pub.dartlang.org" source: hosted - version: "0.12.2" + version: "0.13.0" http_multi_server: dependency: transitive description: name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "2.2.0" + version: "3.0.0" http_parser: dependency: transitive description: name: http_parser url: "https://pub.dartlang.org" source: hosted - version: "3.1.4" + version: "4.0.0" image: dependency: transitive description: name: image url: "https://pub.dartlang.org" source: hosted - version: "2.1.19" + version: "3.0.1" intl: dependency: "direct main" description: name: intl url: "https://pub.dartlang.org" source: hosted - version: "0.16.1" + version: "0.17.0" io: dependency: transitive description: name: io url: "https://pub.dartlang.org" source: hosted - version: "0.3.4" + version: "1.0.0" js: dependency: transitive description: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.3-nullsafety.2" + version: "0.6.3" json_annotation: dependency: transitive description: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "3.1.1" - json_rpc_2: - dependency: transitive - description: - name: json_rpc_2 - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.2" + version: "4.0.0" latlong: dependency: "direct main" description: @@ -512,28 +503,28 @@ packages: name: markdown url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "4.0.0" matcher: dependency: transitive description: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10-nullsafety.1" + version: "0.12.10" material_design_icons_flutter: dependency: "direct main" description: name: material_design_icons_flutter url: "https://pub.dartlang.org" source: hosted - version: "4.0.5855" + version: "4.0.5955" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" mgrs_dart: dependency: transitive description: @@ -547,35 +538,21 @@ packages: name: mime url: "https://pub.dartlang.org" source: hosted - version: "0.9.7" + version: "1.0.0" motion_sensors: dependency: transitive description: name: motion_sensors url: "https://pub.dartlang.org" source: hosted - version: "0.0.5" + version: "0.1.0" nested: dependency: transitive description: name: nested url: "https://pub.dartlang.org" source: hosted - version: "0.0.4" - node_interop: - dependency: transitive - description: - name: node_interop - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - node_io: - dependency: transitive - description: - name: node_io - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" + version: "1.0.0" node_preamble: dependency: transitive description: @@ -589,91 +566,91 @@ packages: name: overlay_support url: "https://pub.dartlang.org" source: hosted - version: "1.0.5" + version: "1.2.0" package_config: dependency: transitive description: name: package_config url: "https://pub.dartlang.org" source: hosted - version: "1.9.3" + version: "2.0.0" package_info: dependency: "direct main" description: name: package_info url: "https://pub.dartlang.org" source: hosted - version: "0.4.3+4" + version: "2.0.0" palette_generator: dependency: "direct main" description: name: palette_generator url: "https://pub.dartlang.org" source: hosted - version: "0.2.3" + version: "0.3.0" panorama: dependency: "direct main" description: name: panorama url: "https://pub.dartlang.org" source: hosted - version: "0.3.1" + version: "0.4.0" path: dependency: transitive description: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.1" + version: "1.8.0" path_drawing: dependency: transitive description: name: path_drawing url: "https://pub.dartlang.org" source: hosted - version: "0.4.1+1" + version: "0.5.0-nullsafety.0" path_parsing: dependency: transitive description: name: path_parsing url: "https://pub.dartlang.org" source: hosted - version: "0.1.4" + version: "0.2.0-nullsafety.0" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+2" + version: "2.0.0" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.1" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "0.0.4+3" + version: "2.0.0" pdf: dependency: "direct main" description: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "3.0.1" pedantic: dependency: "direct main" description: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.10.0-nullsafety.2" + version: "1.11.0" percent_indicator: dependency: "direct main" description: @@ -687,42 +664,42 @@ packages: name: permission_handler url: "https://pub.dartlang.org" source: hosted - version: "5.1.0+2" + version: "6.1.0" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.2" + version: "3.1.0" petitparser: dependency: transitive description: name: petitparser url: "https://pub.dartlang.org" source: hosted - version: "3.1.0" + version: "4.0.2" platform: dependency: transitive description: name: platform url: "https://pub.dartlang.org" source: hosted - version: "3.0.0-nullsafety.2" + version: "3.0.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "2.0.0" pool: dependency: transitive description: name: pool url: "https://pub.dartlang.org" source: hosted - version: "1.5.0-nullsafety.2" + version: "1.5.0" positioned_tap_detector: dependency: transitive description: @@ -736,14 +713,14 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "4.0.0" + version: "5.0.3" process: dependency: transitive description: name: process url: "https://pub.dartlang.org" source: hosted - version: "4.0.0-nullsafety.2" + version: "4.1.0" proj4dart: dependency: transitive description: @@ -757,21 +734,21 @@ packages: name: provider url: "https://pub.dartlang.org" source: hosted - version: "4.3.3" + version: "5.0.0" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.dartlang.org" source: hosted - version: "1.4.4" + version: "2.0.0" qr: dependency: transitive description: name: qr url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "2.0.0" quiver: dependency: transitive description: @@ -785,70 +762,70 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "0.5.12+4" + version: "2.0.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.2+4" + version: "2.0.0" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+11" + version: "2.0.0" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.4" + version: "2.0.0" shared_preferences_web: dependency: transitive description: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.2+7" + version: "2.0.0" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "0.0.2+3" + version: "2.0.0" shelf: dependency: transitive description: name: shelf url: "https://pub.dartlang.org" source: hosted - version: "0.7.9" + version: "1.0.0" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "3.0.0" shelf_static: dependency: transitive description: name: shelf_static url: "https://pub.dartlang.org" source: hosted - version: "0.2.9+2" + version: "1.0.0" shelf_web_socket: dependency: transitive description: name: shelf_web_socket url: "https://pub.dartlang.org" source: hosted - version: "0.2.4" + version: "1.0.0" sky_engine: dependency: transitive description: flutter @@ -860,56 +837,56 @@ packages: name: source_map_stack_trace url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.3" + version: "2.1.0" source_maps: dependency: transitive description: name: source_maps url: "https://pub.dartlang.org" source: hosted - version: "0.10.10-nullsafety.2" + version: "0.10.10" source_span: dependency: transitive description: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety.2" + version: "1.8.1" sqflite: dependency: "direct main" description: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "1.3.2+3" + version: "2.0.0+2" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "1.0.3+1" + version: "2.0.0+2" stack_trace: dependency: transitive description: name: stack_trace url: "https://pub.dartlang.org" source: hosted - version: "1.10.0-nullsafety.1" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" stream_transform: dependency: transitive description: name: stream_transform url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "2.0.0" streams_channel: dependency: "direct main" description: @@ -923,49 +900,49 @@ packages: name: string_scanner url: "https://pub.dartlang.org" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" sync_http: dependency: transitive description: name: sync_http url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.3.0" synchronized: dependency: transitive description: name: synchronized url: "https://pub.dartlang.org" source: hosted - version: "2.2.0+2" + version: "3.0.0" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.dartlang.org" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" test: dependency: "direct dev" description: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.16.0-nullsafety.5" + version: "1.16.5" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.19-nullsafety.2" + version: "0.2.19" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.3.12-nullsafety.5" + version: "0.3.15" transparent_image: dependency: transitive description: @@ -986,7 +963,7 @@ packages: name: typed_data url: "https://pub.dartlang.org" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" unicode: dependency: transitive description: @@ -1000,42 +977,42 @@ packages: name: url_launcher url: "https://pub.dartlang.org" source: hosted - version: "5.7.10" + version: "6.0.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+4" + version: "2.0.0" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+9" + version: "2.0.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "1.0.9" + version: "2.0.2" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.dartlang.org" source: hosted - version: "0.1.5+3" + version: "2.0.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.dartlang.org" source: hosted - version: "0.0.1+3" + version: "2.0.0" validate: dependency: transitive description: @@ -1049,49 +1026,42 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0-nullsafety.3" + version: "2.1.0" version: dependency: "direct main" description: name: version url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "2.0.0" vm_service: dependency: transitive description: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "5.5.0" - vm_service_client: - dependency: transitive - description: - name: vm_service_client - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.6+2" + version: "6.0.1-nullsafety.1" watcher: dependency: transitive description: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "0.9.7+15" + version: "1.0.0" web_socket_channel: dependency: transitive description: name: web_socket_channel url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "2.0.0" webdriver: dependency: transitive description: name: webdriver url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "3.0.0" webkit_inspection_protocol: dependency: transitive description: @@ -1105,7 +1075,7 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "1.7.4+1" + version: "2.0.4" wkt_parser: dependency: transitive description: @@ -1119,21 +1089,21 @@ packages: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.1.2" + version: "0.2.0" xml: dependency: "direct main" description: name: xml url: "https://pub.dartlang.org" source: hosted - version: "4.5.1" + version: "5.0.2" yaml: dependency: transitive description: name: yaml url: "https://pub.dartlang.org" source: hosted - version: "2.2.1" + version: "3.1.0" sdks: - dart: ">=2.10.2 <2.11.0" - flutter: ">=1.22.2 <2.0.0" + dart: ">=2.12.0 <3.0.0" + flutter: ">=1.26.0-0" diff --git a/pubspec.yaml b/pubspec.yaml index eb7bdb767..03095a28a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,36 +1,31 @@ name: aves -description: Aves is a gallery and metadata explorer app, built for Android. - -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -version: 1.3.5+41 - -# brendan-duncan/image (as of v2.1.19): -# - does not support TIFF with JPEG compression (issue #184) -# - TIFF tile decoding is not public (issue #258) - -# video_player (as of v0.10.8+2, backed by ExoPlayer): -# - does not support content URIs (by default, but trivial by fork) -# - does not support AVI/XVID, AC3 -# - cannot play if only the video or audio stream is supported - -# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg): -# - support content URIs -# - does not support XVID, AC3 (by default, but possible by custom build) -# - can play if only the video or audio stream is supported -# - crash when calling `seekTo` for some files (e.g. TED talk videos) - -# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg): -# - support content URIs (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android =2.7.0 <3.0.0" + sdk: '>=2.10.0 <3.0.0' + +# TODO TLAD switch to Flutter stable when possible, currently on dev/beta because of the following mess: +# printing >=5.0.1 depends on pdf ^3.0.1, pdf >=3.0.1 depends on crypto ^3.0.0 and archive ^3.1.0 +# but `flutter_driver` (shipped with Flutter) dependencies are too old in stable v2.0.1 +# bump `crypto` and others - 2021/02/05 https://github.com/flutter/flutter/commit/bc1cf4945841ba5874f5262b8146d52750e7c11f +# bump `archive` from 3.0.0 to 3.1.2 - 2021/03/04 https://github.com/flutter/flutter/commit/ddcb8d7d6d3fcedc906b2f1bf26b73c018d3dc28 + +# not null safe, as of 2021/03/13 +# `charts_flutter` - https://github.com/google/charts/issues/579 +# `decorated_icon` - https://github.com/benPesso/flutter_decorated_icon/issues/2 +# `flutter_ijkplayer` - unmaintained? +# `flutter_map` - https://github.com/fleaflet/flutter_map/issues/829 +# `latlong` - archived - migrate to maps_toolkit? cf https://github.com/fleaflet/flutter_map/pull/750 +# `streams_channel` - unmaintained? - no issue/PR dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter charts_flutter: collection: connectivity: @@ -44,22 +39,22 @@ dependencies: firebase_core: firebase_analytics: firebase_crashlytics: - flushbar: flutter_highlight: flutter_ijkplayer: # path: ../flutter_ijkplayer git: url: git://github.com/deckerst/flutter_ijkplayer.git + flutter_localized_locales: flutter_map: flutter_markdown: flutter_staggered_animations: flutter_svg: - geocoder: + get_it: github: google_api_availability: google_maps_flutter: intl: - latlong: # for flutter_map + latlong: material_design_icons_flutter: overlay_support: package_info: @@ -82,18 +77,56 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - - # run on any device: - # % flutter drive -t test_driver/app.dart - # capture shaders in profile mode (real device only): - # % flutter drive -t test_driver/app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json flutter_driver: sdk: flutter - - test: any + test: flutter: - uses-material-design: true - assets: - assets/ + generate: true + uses-material-design: true + +################################################################################ +# Localization + +# language files: +# - /lib/l10n/app_{language}.arb +# - /android/app/src/main/res/values-{language}/strings.xml +# - /android/app/src/debug/res/values-{language}/strings.xml (optional) +# - /android/app/src/profile/res/values-{language}/strings.xml (optional) + +# generate `AppLocalizations` +# % flutter gen-l10n + +################################################################################ +# Test driver + +# run (any device): +# % flutter drive -t test_driver/app.dart + +# capture shaders in profile mode (real device only): +# % flutter drive -t test_driver/app.dart --profile --cache-sksl --write-sksl-on-exit shaders.sksl.json + +################################################################################ +# Package study + +# brendan-duncan/image (as of v2.1.19): +# - does not support TIFF with JPEG compression (issue #184) +# - TIFF tile decoding is not public (issue #258) + +# video_player (as of v0.10.8+2, backed by ExoPlayer): +# - does not support content URIs (by default, but trivial by fork) +# - does not support AVI/XVID, AC3 +# - cannot play if only the video or audio stream is supported + +# fijkplayer (as of v0.7.1, backed by IJKPlayer & ffmpeg): +# - support content URIs +# - does not support XVID, AC3 (by default, but possible by custom build) +# - can play if only the video or audio stream is supported +# - crash when calling `seekTo` for some files (e.g. TED talk videos) + +# flutter_ijkplayer (as of v0.3.5+1, backed by IJKPlayer & ffmpeg): +# - support content URIs (`DataSource.photoManagerUrl` from v0.3.6, but need fork to support content URIs on Android get canLocatePlaces => SynchronousFuture(false); +} diff --git a/test/fake/image_file_service.dart b/test/fake/image_file_service.dart new file mode 100644 index 000000000..4c4716ae4 --- /dev/null +++ b/test/fake/image_file_service.dart @@ -0,0 +1,21 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'media_store_service.dart'; + +class FakeImageFileService extends Fake implements ImageFileService { + @override + Future rename(AvesEntry entry, String newName) { + final contentId = FakeMediaStoreService.nextContentId; + return SynchronousFuture({ + 'uri': 'content://media/external/images/media/$contentId', + 'contentId': contentId, + 'path': '${entry.directory}/$newName', + 'displayName': newName, + 'title': newName.substring(0, newName.length - entry.extension.length), + 'dateModifiedSecs': FakeMediaStoreService.dateSecs, + }); + } +} diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart new file mode 100644 index 000000000..fce6490e6 --- /dev/null +++ b/test/fake/media_store_service.dart @@ -0,0 +1,62 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/image_op_events.dart'; +import 'package:aves/services/media_store_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeMediaStoreService extends Fake implements MediaStoreService { + Set entries = {}; + + @override + Future> checkObsoleteContentIds(List knownContentIds) => SynchronousFuture([]); + + @override + Future> checkObsoletePaths(Map knownPathById) => SynchronousFuture([]); + + @override + Stream getEntries(Map knownEntries) => Stream.fromIterable(entries); + + static var _lastContentId = 1; + + static int get nextContentId => _lastContentId++; + + static int get dateSecs => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + static AvesEntry newImage(String album, String filenameWithoutExtension) { + final contentId = nextContentId; + final date = dateSecs; + return AvesEntry( + uri: 'content://media/external/images/media/$contentId', + contentId: contentId, + path: '$album/$filenameWithoutExtension.jpg', + pageId: null, + sourceMimeType: MimeTypes.jpeg, + width: 360, + height: 720, + sourceRotationDegrees: 0, + sizeBytes: 42, + sourceTitle: filenameWithoutExtension, + dateModifiedSecs: date, + sourceDateTakenMillis: date, + durationMillis: null, + ); + } + + static MoveOpEvent moveOpEventFor(AvesEntry entry, String sourceAlbum, String destinationAlbum) { + final newContentId = nextContentId; + return MoveOpEvent( + success: true, + uri: entry.uri, + newFields: { + 'deletedSource': true, + 'uri': 'content://media/external/images/media/$newContentId', + 'contentId': newContentId, + 'path': entry.path.replaceFirst(sourceAlbum, destinationAlbum), + 'displayName': '${entry.filenameWithoutExtension}${entry.extension}', + 'title': entry.filenameWithoutExtension, + 'dateModifiedSecs': FakeMediaStoreService.dateSecs, + }, + ); + } +} diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart new file mode 100644 index 000000000..6b0d8b0a5 --- /dev/null +++ b/test/fake/metadata_db.dart @@ -0,0 +1,66 @@ +import 'package:aves/model/covers.dart'; +import 'package:aves/model/entry.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/model/metadata.dart'; +import 'package:aves/model/metadata_db.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeMetadataDb extends Fake implements MetadataDb { + @override + Future init() => null; + + @override + Future removeIds(Set contentIds, {@required bool metadataOnly}) => null; + + @override + Future> loadEntries() => SynchronousFuture({}); + + @override + Future saveEntries(Iterable entries) => null; + + @override + Future updateEntryId(int oldId, AvesEntry entry) => null; + + @override + Future> loadDates() => SynchronousFuture([]); + + @override + Future> loadMetadataEntries() => SynchronousFuture([]); + + @override + Future saveMetadata(Iterable metadataEntries) => null; + + @override + Future updateMetadataId(int oldId, CatalogMetadata metadata) => null; + + @override + Future> loadAddresses() => SynchronousFuture([]); + + @override + Future updateAddressId(int oldId, AddressDetails address) => null; + + @override + Future> loadFavourites() => SynchronousFuture({}); + + @override + Future addFavourites(Iterable rows) => null; + + @override + Future updateFavouriteId(int oldId, FavouriteRow row) => null; + + @override + Future removeFavourites(Iterable rows) => null; + + @override + Future> loadCovers() => SynchronousFuture({}); + + @override + Future addCovers(Iterable rows) => null; + + @override + Future updateCoverEntryId(int oldId, CoverRow row) => null; + + @override + Future removeCovers(Iterable rows) => null; +} diff --git a/test/fake/metadata_service.dart b/test/fake/metadata_service.dart new file mode 100644 index 000000000..766f27caa --- /dev/null +++ b/test/fake/metadata_service.dart @@ -0,0 +1,9 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/metadata.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeMetadataService extends Fake implements MetadataService { + @override + Future getCatalogMetadata(AvesEntry entry, {bool background = false}) => null; +} diff --git a/test/fake/time_service.dart b/test/fake/time_service.dart new file mode 100644 index 000000000..5e7eddee2 --- /dev/null +++ b/test/fake/time_service.dart @@ -0,0 +1,8 @@ +import 'package:aves/services/time_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeTimeService extends Fake implements TimeService { + @override + Future getDefaultTimeZone() => SynchronousFuture(''); +} diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart new file mode 100644 index 000000000..e5d39881a --- /dev/null +++ b/test/model/collection_source_test.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:aves/model/availability.dart'; +import 'package:aves/model/covers.dart'; +import 'package:aves/model/favourites.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/metadata_db.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/enums.dart'; +import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/services/media_store_service.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/services/time_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fake/availability.dart'; +import '../fake/image_file_service.dart'; +import '../fake/media_store_service.dart'; +import '../fake/metadata_db.dart'; +import '../fake/metadata_service.dart'; +import '../fake/time_service.dart'; + +void main() { + const volume = '/storage/emulated/0/'; + const testAlbum = '${volume}Pictures/test'; + const sourceAlbum = '${volume}Pictures/source'; + const destinationAlbum = '${volume}Pictures/destination'; + + setUp(() async { + getIt.registerLazySingleton(() => FakeAvesAvailability()); + getIt.registerLazySingleton(() => FakeMetadataDb()); + + getIt.registerLazySingleton(() => FakeImageFileService()); + getIt.registerLazySingleton(() => FakeMediaStoreService()); + getIt.registerLazySingleton(() => FakeMetadataService()); + getIt.registerLazySingleton(() => FakeTimeService()); + + await settings.init(); + }); + + tearDown(() async { + await getIt.reset(); + }); + + Future _initSource() async { + final source = MediaStoreSource(); + final readyCompleter = Completer(); + source.stateNotifier.addListener(() { + if (source.stateNotifier.value == SourceState.ready) { + readyCompleter.complete(); + } + }); + await source.init(); + await source.refresh(); + await readyCompleter.future; + return source; + } + + test('add/remove favourite entry', () async { + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + await _initSource(); + expect(favourites.count, 0); + + await image1.toggleFavourite(); + expect(favourites.count, 1); + expect(image1.isFavourite, true); + + await image1.toggleFavourite(); + expect(favourites.count, 0); + expect(image1.isFavourite, false); + }); + + test('set/unset entry as album cover', () async { + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + expect(source.rawAlbums.length, 1); + expect(covers.count, 0); + + final albumFilter = AlbumFilter(testAlbum, 'whatever'); + expect(albumFilter.test(image1), true); + expect(covers.count, 0); + expect(covers.coverContentId(albumFilter), null); + + await covers.set(albumFilter, image1.contentId); + expect(covers.count, 1); + expect(covers.coverContentId(albumFilter), image1.contentId); + + await covers.set(albumFilter, null); + expect(covers.count, 0); + expect(covers.coverContentId(albumFilter), null); + }); + + test('favourites and covers are kept when renaming entries', () async { + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + await image1.toggleFavourite(); + final albumFilter = AlbumFilter(testAlbum, 'whatever'); + await covers.set(albumFilter, image1.contentId); + await source.renameEntry(image1, 'image1b.jpg'); + + expect(favourites.count, 1); + expect(image1.isFavourite, true); + expect(covers.count, 1); + expect(covers.coverContentId(albumFilter), image1.contentId); + }); + + test('favourites and covers are cleared when removing entries', () async { + final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + await image1.toggleFavourite(); + final albumFilter = AlbumFilter(image1.directory, 'whatever'); + await covers.set(albumFilter, image1.contentId); + await source.removeEntries({image1.uri}); + + expect(source.rawAlbums.length, 0); + expect(favourites.count, 0); + expect(covers.count, 0); + expect(covers.coverContentId(albumFilter), null); + }); + + test('albums are updated when moving entries', () async { + final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + expect(source.rawAlbums.contains(sourceAlbum), true); + expect(source.rawAlbums.contains(destinationAlbum), false); + + final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); + final destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever'); + expect(sourceAlbumFilter.test(image1), true); + expect(destinationAlbumFilter.test(image1), false); + + await source.updateAfterMove( + todoEntries: {image1}, + copy: false, + destinationAlbum: destinationAlbum, + movedOps: { + FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + }, + ); + + expect(source.rawAlbums.contains(sourceAlbum), false); + expect(source.rawAlbums.contains(destinationAlbum), true); + expect(sourceAlbumFilter.test(image1), false); + expect(destinationAlbumFilter.test(image1), true); + }); + + test('favourites are kept when moving entries', () async { + final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + await image1.toggleFavourite(); + + await source.updateAfterMove( + todoEntries: {image1}, + copy: false, + destinationAlbum: destinationAlbum, + movedOps: { + FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + }, + ); + + expect(favourites.count, 1); + expect(image1.isFavourite, true); + }); + + test('album cover is reset when moving cover entry', () async { + final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + FakeMediaStoreService.newImage(sourceAlbum, 'image2'), + }; + + final source = await _initSource(); + expect(source.rawAlbums.length, 1); + final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); + await covers.set(sourceAlbumFilter, image1.contentId); + + await source.updateAfterMove( + todoEntries: {image1}, + copy: false, + destinationAlbum: destinationAlbum, + movedOps: { + FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + }, + ); + + expect(source.rawAlbums.length, 2); + expect(covers.count, 0); + expect(covers.coverContentId(sourceAlbumFilter), null); + }); + + test('favourites and covers are kept when renaming albums', () async { + final image1 = FakeMediaStoreService.newImage(sourceAlbum, 'image1'); + (mediaStoreService as FakeMediaStoreService).entries = { + image1, + }; + + final source = await _initSource(); + await image1.toggleFavourite(); + var albumFilter = AlbumFilter(sourceAlbum, 'whatever'); + await covers.set(albumFilter, image1.contentId); + await source.renameAlbum(sourceAlbum, destinationAlbum, { + image1 + }, { + FakeMediaStoreService.moveOpEventFor(image1, sourceAlbum, destinationAlbum), + }); + albumFilter = AlbumFilter(destinationAlbum, 'whatever'); + + expect(favourites.count, 1); + expect(image1.isFavourite, true); + expect(covers.count, 1); + expect(covers.coverContentId(albumFilter), image1.contentId); + }); +} diff --git a/test_driver/app.dart b/test_driver/app.dart index b139f282a..abd4bbdd0 100644 --- a/test_driver/app.dart +++ b/test_driver/app.dart @@ -1,5 +1,7 @@ +import 'dart:ui'; + import 'package:aves/main.dart' as app; -import 'package:aves/model/settings/screen_on.dart'; +import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/android_file_service.dart'; import 'package:flutter_driver/driver_extension.dart'; @@ -22,6 +24,7 @@ Future configureAndLaunch() async { await settings.init(); settings.keepScreenOn = KeepScreenOn.always; settings.hasAcceptedTerms = false; + settings.locale = Locale('en'); app.main(); } diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart index 999f2c77b..6df4f8a3f 100644 --- a/test_driver/app_test.dart +++ b/test_driver/app_test.dart @@ -58,7 +58,8 @@ void agreeToTerms() { await driver.tap(find.byValueKey('continue-button')); await driver.waitUntilNoTransientCallbacks(); - expect(await driver.getText(find.byValueKey('appbar-title')), 'Collection'); + // wait for collection loading + await driver.waitForCondition(NoPendingPlatformMessages()); }); } @@ -71,6 +72,7 @@ void visitAbout() { await driver.waitUntilNoTransientCallbacks(); await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); }); } @@ -83,6 +85,7 @@ void visitSettings() { await driver.waitUntilNoTransientCallbacks(); await pressDeviceBackButton(); + await driver.waitUntilNoTransientCallbacks(); }); } @@ -95,6 +98,7 @@ void sortCollection() { await driver.waitUntilNoTransientCallbacks(); await driver.tap(find.byValueKey(EntrySortFactor.date.toString())); + await driver.waitUntilNoTransientCallbacks(); }); } @@ -107,6 +111,7 @@ void groupCollection() { await driver.waitUntilNoTransientCallbacks(); await driver.tap(find.byValueKey(EntryGroupFactor.album.toString())); + await driver.waitUntilNoTransientCallbacks(); }); } @@ -135,13 +140,15 @@ void searchAlbum() { await driver.tap(find.byValueKey('search-button')); await driver.waitUntilNoTransientCallbacks(); - final album = path.split(targetPicturesDir).last; + const albumPath = targetPicturesDirEmulated; + final albumUniqueName = path.split(albumPath).last; await driver.tap(find.byType('TextField')); - await driver.enterText(album); + await driver.enterText(albumUniqueName); - final albumChip = find.byValueKey('album-$album'); + final albumChip = find.byValueKey('album-$albumPath'); await driver.waitFor(albumChip); await driver.tap(albumChip); + await driver.waitUntilNoTransientCallbacks(); }); } @@ -210,6 +217,7 @@ void showInfoMetadata() { print('* back to image'); await driver.tap(find.byValueKey('back-button')); + await driver.waitUntilNoTransientCallbacks(); }); } diff --git a/test_driver/constants.dart b/test_driver/constants.dart index 3e6962cf8..a75c344a8 100644 --- a/test_driver/constants.dart +++ b/test_driver/constants.dart @@ -1,2 +1,3 @@ const sourcePicturesDir = 'test_driver/assets/'; const targetPicturesDir = '/sdcard/Pictures/Aves Test Driver/'; +const targetPicturesDirEmulated = '/storage/emulated/0/Pictures/Aves Test Driver'; \ No newline at end of file diff --git a/test_driver/utils/adb_utils.dart b/test_driver/utils/adb_utils.dart index a8efc11e3..9eacabd7e 100644 --- a/test_driver/utils/adb_utils.dart +++ b/test_driver/utils/adb_utils.dart @@ -19,7 +19,7 @@ String get adb { const List adbDeviceParam = []; // '[]', '[-d]', '[-e]', or '[-s, ]' Future runAdb(List args) async { - await Process.runSync(adb, [...adbDeviceParam, ...args]); + await Process.run(adb, [...adbDeviceParam, ...args]); } Future createDirectory(String dir) async { diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index de7b3de08..56dfa8c15 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,6 @@ Thanks for using Aves! -v1.3.5: -- support Android KitKat, Lollipop & Marshmallow -- quick country reverse geocoding without Play Services -- long press menu on any filter to hide, navigate +v1.3.6: +- Korean translation +- cover selection for albums, countries & tags +- TIFF decoding fixes Full changelog available on Github \ No newline at end of file