Merge branch 'develop' into main

This commit is contained in:
Thibault Deckers 2021-03-18 13:13:04 +09:00
commit 3758e5e4a8
232 changed files with 5307 additions and 2382 deletions

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@
Aves is a gallery and metadata explorer app. It is built for Android, with Flutter.
<img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/2-S10-image.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves/develop/extra/play/screenshots%20v1.2.1/S10/5-S10-stats.png" alt='Stats screenshot' height="400" />
<img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/1-S10-collection.png" alt='Collection screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/2-S10-viewer.png" alt='Image screenshot' height="400" /><img src="https://raw.githubusercontent.com/deckerst/aves_extra/main/screenshots/S10/5-S10-stats.png" alt='Stats screenshot' height="400" />
## Features

View file

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

View file

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

View file

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

View file

@ -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<Number>("latitude")?.toDouble()
val longitude = call.argument<Number>("longitude")?.toDouble()
val localeString = call.argument<String>("locale")
val maxResults = call.argument<Int>("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<Map<String, String?>> = 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"
}
}

View file

@ -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<String, String> {
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<String, String>().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<String, Any?>(
"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"

View file

@ -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<MultiTrackImage, Bitmap> {
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
@ -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 {

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?>
<resources>
<string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string>
</resources>

View file

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

View file

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

View file

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

View file

@ -1,102 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="500"
version="1.1"
id="svg16"
sodipodi:docname="aves_icon_20190726_0032.svg"
inkscape:export-filename="C:\Users\tibo\Downloads\aves0030.png"
inkscape:export-xdpi="98.300003"
inkscape:export-ydpi="98.300003"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata20">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1600"
inkscape:window-height="837"
id="namedview18"
showgrid="false"
fit-margin-top="68"
fit-margin-left="60"
fit-margin-right="60"
fit-margin-bottom="68"
inkscape:zoom="1.37"
inkscape:cx="288.67639"
inkscape:cy="258.4313"
inkscape:window-x="1358"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg16">
<inkscape:grid
type="xygrid"
id="grid28" />
</sodipodi:namedview>
<defs
id="defs6">
<path
d="M 444.44,404.33 221.11,181 h 141.42 l 152.62,152.62 z"
id="a"
inkscape:connector-curvature="0" />
<path
d="m 346.96,501.78 -0.15,-0.15 V 332.22 l 84.86,84.85 z"
id="b"
inkscape:connector-curvature="0" />
<path
d="m 500.23,294.04 -56.56,-56.57 56.47,-56.47 0.09,0.1 z"
id="c"
inkscape:connector-curvature="0" />
<path
d="m 515.15,181 42.39,42.39 h -42.39 z"
id="d"
inkscape:connector-curvature="0" />
</defs>
<g
id="g4567"
transform="translate(-11.68909,3.207375)">
<path
id="rect3756"
d="M 128.91211,145.45703 295.47266,312.01758 339.25,268.24219 216.46484,145.45703 Z"
style="fill:#3f51b5;fill-opacity:1;stroke-width:1.91993546"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="rect4589"
d="m 228.52199,267.34358 v 111.62418 l 55.81209,-55.81209 z"
style="fill:#4caf50;fill-opacity:1;stroke-width:1.2847122" />
<path
inkscape:connector-curvature="0"
id="rect4589-5"
d="M 339.24927,246.24167 V 134.61749 l -55.81209,55.81209 z"
style="fill:#ffc107;fill-opacity:1;stroke-width:1.2847122" />
<path
inkscape:connector-curvature="0"
id="rect4589-5-8"
d="m 394.46607,174.08259 -39.46511,-39.4651 v 39.4651 z"
style="fill:#ff5722;fill-opacity:1;stroke-width:0.6423561" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,011 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 KiB

10
l10n.yaml Normal file
View file

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

9
lib/app_mode.dart Normal file
View file

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

View file

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

View file

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

View file

@ -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<UriImage> {
assert(key == this);
try {
final bytes = await ImageFileService.getImage(
final bytes = await imageFileService.getImage(
uri,
mimeType,
rotationDegrees,

View file

@ -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<UriPicture> {
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<UriPicture> obtainKey(PictureConfiguration configuration) {
return SynchronousFuture<UriPicture>(this);
@ -30,7 +29,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _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;
}

668
lib/l10n/app_en.arb Normal file
View file

@ -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": {}
}

318
lib/l10n/app_ko.arb Normal file
View file

@ -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": "소스 코드"
}

View file

@ -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<AvesApp> {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
@ -59,49 +62,12 @@ class _AvesAppState extends State<AvesApp> {
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _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<AvesApp> {
// so it can be used during navigation transitions
return ChangeNotifierProvider<Settings>.value(
value: settings,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: HighlightInfoProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
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<ValueNotifier<AppMode>>.value(
value: appModeNotifier,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: HighlightInfoProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
final home = initialized
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox(),
);
return Selector<Settings, Locale>(
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<AvesApp> {
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(

View file

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

View file

@ -2,7 +2,6 @@ enum CollectionAction {
addShortcut,
sort,
group,
refresh,
select,
selectAll,
selectNone,

View file

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

View file

@ -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<bool> get isConnected;
Future<bool> get hasPlayServices;
Future<bool> get canLocatePlaces;
Future<bool> get isNewVersionAvailable;
}
class LiveAvesAvailability implements AvesAvailability {
bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
AvesAvailability._private() {
LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
}
@override
void onResume() => _isConnected = null;
@override
Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity());
@ -34,6 +46,7 @@ class AvesAvailability {
}
}
@override
Future<bool> 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<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);

111
lib/model/covers.dart Normal file
View file

@ -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<CoverRow> _rows = {};
Covers._private();
Future<void> 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<void> 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<void> 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<void> removeEntries(Set<AvesEntry> 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<void> 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<String, dynamic> 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}';
}

View file

@ -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<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates;
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> 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<void> locatePlace({@required bool background}) async {
if (!hasGps || hasFineAddress) return;
final coordinates = latLng;
try {
Future<List<Address>> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
Future<List<Address>> 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<String> 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<bool> 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<bool> 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<bool> 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<bool> delete() {
Completer completer = Completer<bool>();
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<void> _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<void> toggleFavourite() async {
if (isFavourite) {
removeFromFavourites();
await removeFromFavourites();
} else {
addToFavourites();
await addToFavourites();
}
}
void addToFavourites() {
Future<void> addToFavourites() async {
if (!isFavourite) {
favourites.add([this]);
await favourites.add([this]);
}
}
void removeFromFavourites() {
Future<void> removeFromFavourites() async {
if (isFavourite) {
favourites.remove([this]);
await favourites.remove([this]);
}
}

View file

@ -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<FavouriteRow> _rows = [];
final AChangeNotifier changeNotifier = AChangeNotifier();
FavouriteRepo._private();
Future<void> 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<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
changeNotifier.notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> entries) async {
final removedRows = entries.map(_entryToRow);
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
changeNotifier.notifyListeners();
}
Future<void> 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<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
changeNotifier.notifyListeners();
}
}

96
lib/model/favourites.dart Normal file
View file

@ -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<FavouriteRow> _rows = {};
Favourites._private();
Future<void> 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<void> add(Iterable<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
await metadataDb.addFavourites(newRows);
_rows.addAll(newRows);
notifyListeners();
}
Future<void> remove(Iterable<AvesEntry> 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<void> 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<void> 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<String, dynamic> 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}';
}

View file

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

View file

@ -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<String, dynamic> 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> color(BuildContext context) => SynchronousFuture(Colors.red);
@override
String get category => type;
@override
String get key => type;
@override
bool operator ==(Object other) {

View file

@ -14,7 +14,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> {
static const List<String> collectionFilterOrder = [
static const List<String> categoryOrder = [
QueryFilter.type,
FavouriteFilter.type,
MimeFilter.type,
@ -57,25 +57,28 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
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> color(BuildContext context) => SynchronousFuture(stringToColor(label));
Future<Color> 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, dynamic> 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}';
}

View file

@ -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<void> init();
class MetadataDb {
Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {@required bool metadataOnly});
// entries
Future<void> clearEntries();
Future<Set<AvesEntry>> loadEntries();
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntryId(int oldId, AvesEntry entry);
// date taken
Future<void> clearDates();
Future<List<DateMetadata>> loadDates();
// catalog metadata
Future<void> clearMetadataEntries();
Future<List<CatalogMetadata>> loadMetadataEntries();
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata);
// address
Future<void> clearAddresses();
Future<List<AddressDetails>> loadAddresses();
Future<void> saveAddresses(Iterable<AddressDetails> addresses);
Future<void> updateAddressId(int oldId, AddressDetails address);
// favourites
Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
// covers
Future<void> clearCovers();
Future<Set<CoverRow>> loadCovers();
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Iterable<CoverRow> rows);
}
class SqfliteMetadataDb implements MetadataDb {
Future<Database> _database;
Future<String> 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<void> 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<int> dbFileSize() async {
final file = File((await path));
return await file.exists() ? file.length() : 0;
}
@override
Future<void> reset() async {
debugPrint('$runtimeType reset');
await (await _database).close();
@ -86,7 +162,8 @@ class MetadataDb {
await init();
}
void removeIds(Set<int> contentIds, {@required bool updateFavourites}) async {
@override
Future<void> removeIds(Set<int> 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<void> clearEntries() async {
final db = await _database;
final count = await db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count entries');
}
@override
Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start();
final db = await _database;
@ -125,6 +205,7 @@ class MetadataDb {
return entries;
}
@override
Future<void> saveEntries(Iterable<AvesEntry> 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<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database;
final batch = db.batch();
@ -154,12 +236,14 @@ class MetadataDb {
// date taken
@override
Future<void> clearDates() async {
final db = await _database;
final count = await db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count entries');
}
@override
Future<List<DateMetadata>> loadDates() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -171,12 +255,14 @@ class MetadataDb {
// catalog metadata
@override
Future<void> clearMetadataEntries() async {
final db = await _database;
final count = await db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
}
@override
Future<List<CatalogMetadata>> loadMetadataEntries() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -186,6 +272,7 @@ class MetadataDb {
return metadataEntries;
}
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -200,6 +287,7 @@ class MetadataDb {
}
}
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
final db = await _database;
final batch = db.batch();
@ -227,12 +315,14 @@ class MetadataDb {
// address
@override
Future<void> clearAddresses() async {
final db = await _database;
final count = await db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count entries');
}
@override
Future<List<AddressDetails>> loadAddresses() async {
// final stopwatch = Stopwatch()..start();
final db = await _database;
@ -242,6 +332,7 @@ class MetadataDb {
return addresses;
}
@override
Future<void> saveAddresses(Iterable<AddressDetails> 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<void> updateAddressId(int oldId, AddressDetails address) async {
final db = await _database;
final batch = db.batch();
@ -271,31 +363,31 @@ class MetadataDb {
// favourites
@override
Future<void> clearFavourites() async {
final db = await _database;
final count = await db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count entries');
}
Future<List<FavouriteRow>> loadFavourites() async {
// final stopwatch = Stopwatch()..start();
@override
Future<Set<FavouriteRow>> 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<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
// final stopwatch = Stopwatch()..start();
@override
Future<void> addFavourites(Iterable<FavouriteRow> 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<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database;
final batch = db.batch();
@ -313,9 +405,10 @@ class MetadataDb {
);
}
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async {
if (favouriteRows == null || favouriteRows.isEmpty) return;
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId);
@override
Future<void> removeFavourites(Iterable<FavouriteRow> 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<void> clearCovers() async {
final db = await _database;
final count = await db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count entries');
}
@override
Future<Set<CoverRow>> 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<void> addCovers(Iterable<CoverRow> 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<void> 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<void> removeCovers(Iterable<CoverRow> 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);
}
}

View file

@ -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<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}
}

View file

@ -71,7 +71,7 @@ class SinglePageInfo implements Comparable<SinglePageInfo> {
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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String> uris) {
Future<void> removeEntries(Set<String> 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<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async {
Future<void> _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<bool> 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<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> 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<void> updateAfterMove({
@required Set<AvesEntry> todoEntries,
@required Set<AvesEntry> favouriteEntries,
@required bool copy,
@required String destinationAlbum,
@required Set<MoveOpEvent> 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<AvesEntry> entries;

View file

@ -1,5 +1,7 @@
enum Activity { browse, select }
enum SourceState { loading, cataloguing, locating, ready }
enum ChipSortFactor { date, name, count }
enum AlbumChipGroupFactor { none, importance, volume }

View file

@ -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<int, int> 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<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
}
final knownLocations = <Tuple2, AddressDetails>{};
final knownLocations = <Tuple2<int, int>, 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);

View file

@ -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<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false);
metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh();
}
}

View file

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

View file

@ -41,7 +41,6 @@ class AndroidAppService {
static Future<bool> edit(String uri, String mimeType) async {
try {
return await platform.invokeMethod('edit', <String, dynamic>{
'title': 'Edit with:',
'uri': uri,
'mimeType': mimeType,
});
@ -54,7 +53,6 @@ class AndroidAppService {
static Future<bool> open(String uri, String mimeType) async {
try {
return await platform.invokeMethod('open', <String, dynamic>{
'title': 'Open with:',
'uri': uri,
'mimeType': mimeType,
});
@ -78,7 +76,6 @@ class AndroidAppService {
static Future<bool> setAs(String uri, String mimeType) async {
try {
return await platform.invokeMethod('setAs', <String, dynamic>{
'title': 'Set as:',
'uri': uri,
'mimeType': mimeType,
});
@ -94,7 +91,6 @@ class AndroidAppService {
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try {
return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:',
'urisByMimeType': urisByMimeType,
});
} on PlatformException catch (e) {
@ -106,7 +102,6 @@ class AndroidAppService {
static Future<bool> shareSingle(String uri, String mimeType) async {
try {
return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:',
'urisByMimeType': {
mimeType: [uri]
},

View file

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

View file

@ -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<List<Address>> getAddress(LatLng coordinates, String locale) async {
try {
final result = await platform.invokeMethod('getAddress', <String, dynamic>{
'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((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'],
);
}

View file

@ -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<AvesEntry> getEntry(String uri, String mimeType);
Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
BytesReceivedCallback onBytesReceived,
});
Future<Uint8List> 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<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
bool isFlipped,
int sampleSize,
Rectangle<int> regionRect,
Size imageSize, {
int pageId,
Object taskKey,
int priority,
});
Future<Uint8List> 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<void> clearSizedThumbnailDiskCache();
bool cancelRegion(Object taskKey);
bool cancelThumbnail(Object taskKey);
Future<T> resumeLoading<T>(Object taskKey);
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries);
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
});
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
});
Future<Map> rename(AvesEntry entry, String newName);
Future<Map> rotate(AvesEntry entry, {@required bool clockwise});
Future<Map> 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<AvesEntry> getEntry(String uri, String mimeType) async {
@override
Future<AvesEntry> getEntry(String uri, String mimeType) async {
try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri,
@ -44,7 +120,8 @@ class ImageFileService {
return null;
}
static Future<Uint8List> getSvg(
@override
Future<Uint8List> getSvg(
String uri,
String mimeType, {
int expectedContentLength,
@ -59,7 +136,8 @@ class ImageFileService {
onBytesReceived: onBytesReceived,
);
static Future<Uint8List> getImage(
@override
Future<Uint8List> 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<Uint8List> getRegion(
@override
Future<Uint8List> getRegion(
String uri,
String mimeType,
int rotationDegrees,
@ -145,7 +223,8 @@ class ImageFileService {
);
}
static Future<Uint8List> getThumbnail({
@override
Future<Uint8List> getThumbnail({
@required String uri,
@required String mimeType,
@required int rotationDegrees,
@ -184,7 +263,8 @@ class ImageFileService {
);
}
static Future<void> clearSizedThumbnailDiskCache() async {
@override
Future<void> 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<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
@override
Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
@override
Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete',
@ -210,7 +294,8 @@ class ImageFileService {
}
}
static Stream<MoveOpEvent> move(
@override
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, {
@required bool copy,
@required String destinationAlbum,
@ -228,7 +313,8 @@ class ImageFileService {
}
}
static Stream<ExportOpEvent> export(
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg,
@required String destinationAlbum,
@ -246,7 +332,8 @@ class ImageFileService {
}
}
static Future<Map> rename(AvesEntry entry, String newName) async {
@override
Future<Map> rename(AvesEntry entry, String newName) async {
try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -260,7 +347,8 @@ class ImageFileService {
return {};
}
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
@override
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -274,7 +362,8 @@ class ImageFileService {
return {};
}
static Future<Map> flip(AvesEntry entry) async {
@override
Future<Map> flip(AvesEntry entry) async {
try {
// returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{

View file

@ -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<List<int>> checkObsoleteContentIds(List<int> knownContentIds);
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById);
// knownEntries: map of contentId -> dateModifiedSecs
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
}
class PlatformMediaStoreService implements MediaStoreService {
static const platform = MethodChannel('deckers.thibault/aves/mediastore');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
@override
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds,
@ -21,7 +31,8 @@ class MediaStoreService {
return [];
}
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
@override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById,
@ -33,8 +44,8 @@ class MediaStoreService {
return [];
}
// knownEntries: map of contentId -> dateModifiedSecs
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
@override
Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,

View file

@ -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<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
Future<Map> getAllMetadata(AvesEntry entry);
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false});
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry);
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry);
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry);
Future<String> getContentResolverProp(AvesEntry entry, String prop);
Future<List<Uint8List>> getEmbeddedPictures(String uri);
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry);
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType);
}
class PlatformMetadataService implements MetadataService {
static const platform = MethodChannel('deckers.thibault/aves/metadata');
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
static Future<Map> getAllMetadata(AvesEntry entry) async {
@override
Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null;
try {
@ -28,7 +49,8 @@ class MetadataService {
return {};
}
static Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
@override
Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
if (entry.isSvg) return null;
Future<CatalogMetadata> call() async {
@ -65,7 +87,8 @@ class MetadataService {
: call();
}
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
@override
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null;
try {
@ -82,7 +105,8 @@ class MetadataService {
return null;
}
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
@override
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType,
@ -96,7 +120,8 @@ class MetadataService {
return null;
}
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
@override
Future<PanoramaInfo> 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<String> getContentResolverProp(AvesEntry entry, String prop) async {
@override
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'mimeType': entry.mimeType,
@ -126,7 +152,8 @@ class MetadataService {
return null;
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
@override
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri,
@ -138,7 +165,8 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
@override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType,
@ -152,7 +180,8 @@ class MetadataService {
return [];
}
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
@override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType,

View file

@ -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<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
final timeService = getIt<TimeService>();
void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
}

View file

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

View file

@ -1,10 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class TimeService {
abstract class TimeService {
Future<String> getDefaultTimeZone();
}
class PlatformTimeService implements TimeService {
static const platform = MethodChannel('deckers.thibault/aves/time');
static Future<String> getDefaultTimeZone() async {
@override
Future<String> getDefaultTimeZone() async {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) {

View file

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

View file

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

51
lib/theme/themes.dart Normal file
View file

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

View file

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

View file

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

View file

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

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