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: steps:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: dev
flutter-version: '1.22.6' flutter-version: '2.1.0-12.1.pre'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -16,8 +16,8 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: dev
flutter-version: '1.22.6' flutter-version: '2.1.0-12.1.pre'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441 # https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build apk --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_1.22.6.sksl.json flutter build appbundle --bundle-sksl-path shaders_2.1.0-12.1.pre.sksl.json
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks 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] ## [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 ## [v1.3.5] - 2021-02-26
### Added ### Added
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23) - 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. 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 ## Features

View file

@ -104,11 +104,11 @@ repositories {
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' 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 'androidx.exifinterface:exifinterface:1.3.2'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' 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' implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.1.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, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(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.bumptech.glide.load.resource.bitmap.TransformationUtils
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag
import com.drew.metadata.exif.* import com.drew.metadata.exif.*
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory 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.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis 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.getSafeLocalizedText
import deckers.thibault.aves.metadata.XMP.getSafeString
import deckers.thibault.aves.metadata.XMP.isPanorama import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.FileImageProvider import deckers.thibault.aves.model.provider.FileImageProvider
@ -123,17 +126,29 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[dirName] = dirMap metadataMap[dirName] = dirMap
// tags // tags
val tags = dir.tags
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) { 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()) { val name = if (it.hasTagName()) {
it.tagName it.tagName
} else { } else {
TiffTags.getTagName(it.tagType) ?: it.tagName 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 { } else {
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) }) dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
} }
if (dir is XmpDirectory) { if (dir is XmpDirectory) {
try { try {
@ -593,10 +608,11 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
KEY_PAGE to i, KEY_PAGE to i,
KEY_MIME_TYPE to trackMime, 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 } 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_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (isVideo(trackMime)) { if (isVideo(trackMime)) {
@ -626,25 +642,21 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) val fields = hashMapOf<String, Any?>(
try { "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
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 } for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val fields: FieldMap = hashMapOf( val xmpMeta = dir.xmpMeta
"croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
"croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
"croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME) { fields["croppedAreaWidth"] = it }
"croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME) { fields["croppedAreaHeight"] = it }
"fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME) { fields["fullPanoWidth"] = it }
"fullPanoHeight" to getIntProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME), xmpMeta.getSafeInt(XMP.GPANO_SCHEMA_NS, XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME) { fields["fullPanoHeight"] = it }
"projectionType" to (getStringProp(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT), xmpMeta.getSafeString(XMP.GPANO_SCHEMA_NS, XMP.GPANO_PROJECTION_TYPE_PROP_NAME) { fields["projectionType"] = it }
)
result.success(fields)
return
} catch (e: XMPException) {
result.error("getPanoramaInfo-args", "failed to read XMP for uri=$uri", e.message)
return
} }
result.success(fields)
return
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to read XMP", e) 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_HEIGHT = "height"
private const val KEY_WIDTH = "width" private const val KEY_WIDTH = "width"
private const val KEY_PAGE = "page" private const val KEY_PAGE = "page"
private const val KEY_TRACK_ID = "trackId"
private const val KEY_IS_DEFAULT = "isDefault" private const val KEY_IS_DEFAULT = "isDefault"
private const val KEY_DURATION = "durationMillis" 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> { internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<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 context = model.context
val uri = model.uri 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) { if (bitmap == null) {
callback.onLoadFailed(Exception("null bitmap")) callback.onLoadFailed(Exception("null bitmap"))
} else { } else {

View file

@ -16,17 +16,17 @@ object MultiTrackMedia {
private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java) private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java)
@RequiresApi(Build.VERSION_CODES.P) @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 val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return null
try { try {
return if (trackId != null) { return if (trackIndex != null) {
val imageIndex = trackIdToImageIndex(context, uri, trackId) ?: return null val imageIndex = trackIndexToImageIndex(context, uri, trackIndex) ?: return null
retriever.getImageAtIndex(imageIndex) retriever.getImageAtIndex(imageIndex)
} else { } else {
retriever.primaryImage retriever.primaryImage
} }
} catch (e: Exception) { } 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 { } finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release() retriever.release()
@ -34,7 +34,7 @@ object MultiTrackMedia {
return null 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() val extractor = MediaExtractor()
try { try {
extractor.setDataSource(context, uri, null) extractor.setDataSource(context, uri, null)
@ -42,7 +42,7 @@ object MultiTrackMedia {
var imageIndex = 0 var imageIndex = 0
for (i in 0 until trackCount) { for (i in 0 until trackCount) {
val trackFormat = extractor.getTrackFormat(i) val trackFormat = extractor.getTrackFormat(i)
if (trackId == trackFormat.getInteger(MediaFormat.KEY_TRACK_ID)) { if (trackIndex == i) {
return imageIndex return imageIndex
} }
if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) { if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) {
@ -50,7 +50,7 @@ object MultiTrackMedia {
} }
} }
} catch (e: Exception) { } 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 { } finally {
extractor.release() extractor.release()
} }

View file

@ -110,6 +110,15 @@ object TiffTags {
// Count = variable // Count = variable
const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b 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( private val tagNameMap = hashMapOf(
TAG_X_POSITION to "X Position", TAG_X_POSITION to "X Position",
TAG_Y_POSITION to "Y Position", TAG_Y_POSITION to "Y Position",
@ -132,6 +141,8 @@ object TiffTags {
TAG_ORIGINAL_RAW_FILE_NAME to "Original Raw File Name", TAG_ORIGINAL_RAW_FILE_NAME to "Original Raw File Name",
) )
fun isGeoTiffTag(tag: Int) = geotiffTags.contains(tag)
fun getTagName(tag: Int): String? { fun getTagName(tag: Int): String? {
return tagNameMap[tag] return tagNameMap[tag]
} }

View file

@ -97,6 +97,34 @@ object XMP {
return false 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) { fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, acceptBlank: Boolean = true, save: (value: String) -> Unit) {
try { try {
if (doesPropertyExist(schema, propName)) { 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. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.30' ext.kotlin_version = '1.4.31'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387 // 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 { 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:4.1.2'
classpath 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.5' 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() google()
mavenCentral() mavenCentral()
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387 // 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 { // gradle.projectsEvaluated {
// tasks.withType(JavaCompile) { // tasks.withType(JavaCompile) {

View file

@ -15,4 +15,3 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official 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:math';
import 'dart:ui' as ui show Codec; 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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -32,7 +32,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getRegion( final bytes = await imageFileService.getRegion(
uri, uri,
mimeType, mimeType,
key.rotationDegrees, key.rotationDegrees,
@ -55,11 +55,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key); imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelRegion(key); void pause() => imageFileService.cancelRegion(key);
} }
class RegionProviderKey { class RegionProviderKey {

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec; 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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -33,7 +33,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await ImageFileService.getThumbnail( final bytes = await imageFileService.getThumbnail(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
@ -55,11 +55,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
@override @override
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) { void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
ImageFileService.resumeLoading(key); imageFileService.resumeLoading(key);
super.resolveStreamForKey(configuration, stream, key, handleError); super.resolveStreamForKey(configuration, stream, key, handleError);
} }
void pause() => ImageFileService.cancelThumbnail(key); void pause() => imageFileService.cancelThumbnail(key);
} }
class ThumbnailProviderKey { class ThumbnailProviderKey {

View file

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui show Codec; 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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
@ -46,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
assert(key == this); assert(key == this);
try { try {
final bytes = await ImageFileService.getImage( final bytes = await imageFileService.getImage(
uri, uri,
mimeType, mimeType,
rotationDegrees, 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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@ -8,13 +8,12 @@ class UriPicture extends PictureProvider<UriPicture> {
const UriPicture({ const UriPicture({
@required this.uri, @required this.uri,
@required this.mimeType, @required this.mimeType,
this.colorFilter, ColorFilter colorFilter,
}) : assert(uri != null); }) : assert(uri != null),
super(colorFilter);
final String uri, mimeType; final String uri, mimeType;
final ColorFilter colorFilter;
@override @override
Future<UriPicture> obtainKey(PictureConfiguration configuration) { Future<UriPicture> obtainKey(PictureConfiguration configuration) {
return SynchronousFuture<UriPicture>(this); return SynchronousFuture<UriPicture>(this);
@ -30,7 +29,7 @@ class UriPicture extends PictureProvider<UriPicture> {
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async { Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this); assert(key == this);
final data = await ImageFileService.getSvg(uri, mimeType); final data = await imageFileService.getSvg(uri, mimeType);
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
return null; 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:isolate';
import 'dart:ui'; import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_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/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart'; import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:overlay_support/overlay_support.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -37,16 +43,13 @@ void main() {
runApp(AvesApp()); runApp(AvesApp());
} }
enum AppMode { main, pick, view }
class AvesApp extends StatefulWidget { class AvesApp extends StatefulWidget {
static AppMode mode;
@override @override
_AvesAppState createState() => _AvesAppState(); _AvesAppState createState() => _AvesAppState();
} }
class _AvesAppState extends State<AvesApp> { class _AvesAppState extends State<AvesApp> {
final ValueNotifier<AppMode> appModeNotifier = ValueNotifier(AppMode.main);
Future<void> _appSetup; Future<void> _appSetup;
final _mediaStoreSource = MediaStoreSource(); final _mediaStoreSource = MediaStoreSource();
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay); 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 EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); 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(); Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
initPlatformServices();
_appSetup = _setup(); _appSetup = _setup();
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String)); _contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map)); _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 // so it can be used during navigation transitions
return ChangeNotifierProvider<Settings>.value( return ChangeNotifierProvider<Settings>.value(
value: settings, value: settings,
child: Provider<CollectionSource>.value( child: ListenableProvider<ValueNotifier<AppMode>>.value(
value: _mediaStoreSource, value: appModeNotifier,
child: HighlightInfoProvider( child: Provider<CollectionSource>.value(
child: OverlaySupport( value: _mediaStoreSource,
child: FutureBuilder<void>( child: HighlightInfoProvider(
future: _appSetup, child: OverlaySupport(
builder: (context, snapshot) { child: FutureBuilder<void>(
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) future: _appSetup,
? getFirstPage() builder: (context, snapshot) {
: Scaffold( final initialized = !snapshot.hasError && snapshot.connectionState == ConnectionState.done;
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), final home = initialized
); ? getFirstPage()
return MaterialApp( : Scaffold(
navigatorKey: _navigatorKey, body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox(),
home: home, );
navigatorObservers: _navigatorObservers, return Selector<Settings, Locale>(
title: 'Aves', selector: (context, s) => s.locale,
darkTheme: darkTheme, builder: (context, settingsLocale, child) {
themeMode: ThemeMode.dark, 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'); debugPrint('$runtimeType onNewIntent with intentData=$intentData');
// do not reset when relaunching the app // 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'); FirebaseCrashlytics.instance.log('New intent');
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute( _navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(

View file

@ -1,10 +1,10 @@
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum ChipSetAction { enum ChipSetAction {
group, group,
sort, sort,
refresh,
stats, stats,
} }
@ -14,30 +14,33 @@ enum ChipAction {
pin, pin,
unpin, unpin,
rename, rename,
setCover,
goToAlbumPage, goToAlbumPage,
goToCountryPage, goToCountryPage,
goToTagPage, goToTagPage,
} }
extension ExtraChipAction on ChipAction { extension ExtraChipAction on ChipAction {
String getText() { String getText(BuildContext context) {
switch (this) { switch (this) {
case ChipAction.delete: case ChipAction.delete:
return 'Delete'; return context.l10n.chipActionDelete;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
return 'Show in Albums'; return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage: case ChipAction.goToCountryPage:
return 'Show in Countries'; return context.l10n.chipActionGoToCountryPage;
case ChipAction.goToTagPage: case ChipAction.goToTagPage:
return 'Show in Tags'; return context.l10n.chipActionGoToTagPage;
case ChipAction.hide: case ChipAction.hide:
return 'Hide'; return context.l10n.chipActionHide;
case ChipAction.pin: case ChipAction.pin:
return 'Pin to top'; return context.l10n.chipActionPin;
case ChipAction.unpin: case ChipAction.unpin:
return 'Unpin from top'; return context.l10n.chipActionUnpin;
case ChipAction.rename: case ChipAction.rename:
return 'Rename'; return context.l10n.chipActionRename;
case ChipAction.setCover:
return context.l10n.chipActionSetCover;
} }
return null; return null;
} }
@ -59,6 +62,8 @@ extension ExtraChipAction on ChipAction {
return AIcons.pin; return AIcons.pin;
case ChipAction.rename: case ChipAction.rename:
return AIcons.rename; return AIcons.rename;
case ChipAction.setCover:
return AIcons.setCover;
} }
return null; return null;
} }

View file

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

View file

@ -1,4 +1,5 @@
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum EntryAction { enum EntryAction {
@ -46,41 +47,41 @@ class EntryActions {
} }
extension ExtraEntryAction on EntryAction { extension ExtraEntryAction on EntryAction {
String getText() { String getText(BuildContext context) {
switch (this) { switch (this) {
// in app actions // in app actions
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
// different data depending on toggle state // different data depending on toggle state
return null; return null;
case EntryAction.delete: case EntryAction.delete:
return 'Delete'; return context.l10n.entryActionDelete;
case EntryAction.export: case EntryAction.export:
return 'Export'; return context.l10n.entryActionExport;
case EntryAction.info: case EntryAction.info:
return 'Info'; return context.l10n.entryActionInfo;
case EntryAction.rename: case EntryAction.rename:
return 'Rename'; return context.l10n.entryActionRename;
case EntryAction.rotateCCW: case EntryAction.rotateCCW:
return 'Rotate counterclockwise'; return context.l10n.entryActionRotateCCW;
case EntryAction.rotateCW: case EntryAction.rotateCW:
return 'Rotate clockwise'; return context.l10n.entryActionRotateCW;
case EntryAction.flip: case EntryAction.flip:
return 'Flip horizontally'; return context.l10n.entryActionFlip;
case EntryAction.print: case EntryAction.print:
return 'Print'; return context.l10n.entryActionPrint;
case EntryAction.share: case EntryAction.share:
return 'Share'; return context.l10n.entryActionShare;
case EntryAction.viewSource: case EntryAction.viewSource:
return 'View source'; return context.l10n.entryActionViewSource;
// external app actions // external app actions
case EntryAction.edit: case EntryAction.edit:
return 'Edit with…'; return context.l10n.entryActionEdit;
case EntryAction.open: case EntryAction.open:
return 'Open with…'; return context.l10n.entryActionOpen;
case EntryAction.setAs: case EntryAction.setAs:
return 'Set as…'; return context.l10n.entryActionSetAs;
case EntryAction.openMap: case EntryAction.openMap:
return 'Show on map…'; return context.l10n.entryActionOpenMap;
case EntryAction.debug: case EntryAction.debug:
return '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:package_info/package_info.dart';
import 'package:version/version.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; bool _isConnected, _hasPlayServices, _isNewVersionAvailable;
AvesAvailability._private() { LiveAvesAvailability() {
Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult);
} }
@override
void onResume() => _isConnected = null; void onResume() => _isConnected = null;
@override
Future<bool> get isConnected async { Future<bool> get isConnected async {
if (_isConnected != null) return SynchronousFuture(_isConnected); if (_isConnected != null) return SynchronousFuture(_isConnected);
final result = await (Connectivity().checkConnectivity()); final result = await (Connectivity().checkConnectivity());
@ -34,6 +46,7 @@ class AvesAvailability {
} }
} }
@override
Future<bool> get hasPlayServices async { Future<bool> get hasPlayServices async {
if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices); if (_hasPlayServices != null) return SynchronousFuture(_hasPlayServices);
final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability(); final result = await GoogleApiAvailability.instance.checkGooglePlayServicesAvailability();
@ -43,8 +56,10 @@ class AvesAvailability {
} }
// local geocoding with `geocoder` requires Play Services // local geocoding with `geocoder` requires Play Services
@override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
@override
Future<bool> get isNewVersionAvailable async { Future<bool> get isNewVersionAvailable async {
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); 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 'dart:async';
import 'package:aves/geo/countries.dart'; import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry_cache.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.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.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/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.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:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
import 'package:path/path.dart' as ppath; import 'package:path/path.dart' as ppath;
@ -33,7 +31,7 @@ class AvesEntry {
int height; int height;
int sourceRotationDegrees; int sourceRotationDegrees;
final int sizeBytes; final int sizeBytes;
String sourceTitle; String _sourceTitle;
// `dateModifiedSecs` can be missing in viewer mode // `dateModifiedSecs` can be missing in viewer mode
int _dateModifiedSecs; int _dateModifiedSecs;
@ -45,10 +43,6 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); 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 // TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd]; static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
@ -62,13 +56,14 @@ class AvesEntry {
@required this.height, @required this.height,
this.sourceRotationDegrees, this.sourceRotationDegrees,
this.sizeBytes, this.sizeBytes,
this.sourceTitle, String sourceTitle,
int dateModifiedSecs, int dateModifiedSecs,
this.sourceDateTakenMillis, this.sourceDateTakenMillis,
this.durationMillis, this.durationMillis,
}) : assert(width != null), }) : assert(width != null),
assert(height != null) { assert(height != null) {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs; this.dateModifiedSecs = dateModifiedSecs;
} }
@ -77,14 +72,14 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({ AvesEntry copyWith({
@required String uri, String uri,
@required String path, String path,
@required int contentId, int contentId,
@required int dateModifiedSecs, int dateModifiedSecs,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry( final copied = AvesEntry(
uri: uri ?? uri, uri: uri ?? this.uri,
path: path ?? this.path, path: path ?? this.path,
contentId: copyContentId, contentId: copyContentId,
sourceMimeType: sourceMimeType, sourceMimeType: sourceMimeType,
@ -93,7 +88,7 @@ class AvesEntry {
sourceRotationDegrees: sourceRotationDegrees, sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
sourceTitle: sourceTitle, sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
) )
@ -241,9 +236,7 @@ class AvesEntry {
bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff; 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 bool get useTiles => supportTiling && (width > 4096 || height > 4096);
// 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 isRaw => MimeTypes.rawImages.contains(mimeType); bool get isRaw => MimeTypes.rawImages.contains(mimeType);
@ -347,6 +340,13 @@ class AvesEntry {
set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;
String get sourceTitle => _sourceTitle;
set sourceTitle(String sourceTitle) {
_sourceTitle = sourceTitle;
_bestTitle = null;
}
int get dateModifiedSecs => _dateModifiedSecs; int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) { set dateModifiedSecs(int dateModifiedSecs) {
@ -444,7 +444,7 @@ class AvesEntry {
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);
} else { } 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 // full reverse geocoding, requiring Play Services and some connectivity
Future<void> locatePlace({@required bool background}) async { Future<void> locatePlace({@required bool background}) async {
if (!hasGps || hasFineAddress) return; if (!hasGps || hasFineAddress) return;
final coordinates = latLng;
try { try {
Future<List<Address>> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude)); Future<List<Address>> call() => GeocodingService.getAddress(latLng, geocoderLocale);
final addresses = await (background final addresses = await (background
? servicePolicy.call( ? servicePolicy.call(
call, call,
@ -507,22 +513,21 @@ class AvesEntry {
); );
} }
} catch (error, stack) { } 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 { Future<String> findAddressLine() async {
if (!hasGps) return null; if (!hasGps) return null;
final coordinates = latLng;
try { try {
final addresses = await _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude)); final addresses = await GeocodingService.getAddress(latLng, geocoderLocale);
if (addresses != null && addresses.isNotEmpty) { if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first; final address = addresses.first;
return address.addressLine; return address.addressLine;
} }
} catch (error, stack) { } 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; return null;
} }
@ -553,10 +558,7 @@ class AvesEntry {
final contentId = newFields['contentId']; final contentId = newFields['contentId'];
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields['title'];
if (sourceTitle is String) { if (sourceTitle is String) this.sourceTitle = sourceTitle;
this.sourceTitle = sourceTitle;
_bestTitle = null;
}
final width = newFields['width']; final width = newFields['width'];
if (width is int) this.width = width; if (width is int) this.width = width;
@ -576,18 +578,8 @@ class AvesEntry {
metadataChangeNotifier.notifyListeners(); 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 { 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; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -599,7 +591,7 @@ class AvesEntry {
} }
Future<bool> flip() async { Future<bool> flip() async {
final newFields = await ImageFileService.flip(this); final newFields = await imageFileService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs; final oldDateModifiedSecs = dateModifiedSecs;
@ -612,7 +604,7 @@ class AvesEntry {
Future<bool> delete() { Future<bool> delete() {
Completer completer = Completer<bool>(); Completer completer = Completer<bool>();
ImageFileService.delete([this]).listen( imageFileService.delete([this]).listen(
(event) => completer.complete(event.success), (event) => completer.complete(event.success),
onError: completer.completeError, onError: completer.completeError,
onDone: () { onDone: () {
@ -625,7 +617,7 @@ class AvesEntry {
} }
// when the entry image itself changed (e.g. after rotation) // 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) { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners(); imageChangeNotifier.notifyListeners();
@ -634,23 +626,23 @@ class AvesEntry {
// favourites // favourites
void toggleFavourite() { Future<void> toggleFavourite() async {
if (isFavourite) { if (isFavourite) {
removeFromFavourites(); await removeFromFavourites();
} else { } else {
addToFavourites(); await addToFavourites();
} }
} }
void addToFavourites() { Future<void> addToFavourites() async {
if (!isFavourite) { if (!isFavourite) {
favourites.add([this]); await favourites.add([this]);
} }
} }
void removeFromFavourites() { Future<void> removeFromFavourites() async {
if (isFavourite) { 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; EntryFilter get test => (entry) => entry.directory == album;
@override @override
String get label => uniqueName ?? album.split(separator).last; String get universalLabel => uniqueName ?? album.split(separator).last;
@override @override
String get tooltip => album; String getTooltip(BuildContext context) => album;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
@ -74,7 +74,11 @@ class AlbumFilter extends CollectionFilter {
} }
@override @override
String get typeKey => type; String get category => type;
// key `album-{path}` is expected by test driver
@override
String get key => '$type-$album';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -1,11 +1,15 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class FavouriteFilter extends CollectionFilter { class FavouriteFilter extends CollectionFilter {
static const type = 'favourite'; static const type = 'favourite';
const FavouriteFilter();
@override @override
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'type': type, 'type': type,
@ -15,13 +19,22 @@ class FavouriteFilter extends CollectionFilter {
EntryFilter get test => (entry) => entry.isFavourite; EntryFilter get test => (entry) => entry.isFavourite;
@override @override
String get label => 'Favourite'; String get universalLabel => type;
@override
String getLabel(BuildContext context) => context.l10n.filterFavouriteLabel;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.favourite, size: size);
@override @override
String get typeKey => type; Future<Color> color(BuildContext context) => SynchronousFuture(Colors.red);
@override
String get category => type;
@override
String get key => type;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -14,7 +14,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> { abstract class CollectionFilter implements Comparable<CollectionFilter> {
static const List<String> collectionFilterOrder = [ static const List<String> categoryOrder = [
QueryFilter.type, QueryFilter.type,
FavouriteFilter.type, FavouriteFilter.type,
MimeFilter.type, MimeFilter.type,
@ -57,25 +57,28 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
bool get isUnique => true; 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}); 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; String get category;
int get displayPriority => collectionFilterOrder.indexOf(typeKey);
// to be used as widget key // to be used as widget key
String get key => '$typeKey-$label'; String get key;
int get displayPriority => categoryOrder.indexOf(category);
@override @override
int compareTo(CollectionFilter other) { int compareTo(CollectionFilter other) {
final c = displayPriority.compareTo(other.displayPriority); 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/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class LocationFilter extends CollectionFilter { class LocationFilter extends CollectionFilter {
static const type = 'location'; static const type = 'location';
static const emptyLabel = 'unlocated';
static const locationSeparator = ';'; static const locationSeparator = ';';
final LocationLevel level; final LocationLevel level;
@ -48,7 +48,10 @@ class LocationFilter extends CollectionFilter {
EntryFilter get test => _test; EntryFilter get test => _test;
@override @override
String get label => _location.isEmpty ? emptyLabel : _location; String get universalLabel => _location;
@override
String getLabel(BuildContext context) => _location.isEmpty ? context.l10n.filterLocationEmptyLabel : _location;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) { Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) {
@ -66,7 +69,10 @@ class LocationFilter extends CollectionFilter {
} }
@override @override
String get typeKey => type; String get category => type;
@override
String get key => '$type-$level-$_location';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -1,6 +1,8 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -17,14 +19,12 @@ class MimeFilter extends CollectionFilter {
if (lowMime.endsWith('/*')) { if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2); lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime); _test = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') { _label = lowMime.toUpperCase();
_label = 'Video'; if (mime == MimeTypes.anyImage) {
_icon = AIcons.video;
} else if (lowMime == 'image') {
_label = 'Image';
_icon = AIcons.image; _icon = AIcons.image;
} else if (mime == MimeTypes.anyVideo) {
_icon = AIcons.video;
} }
_label ??= lowMime.split('/')[0].toUpperCase();
} else { } else {
_test = (entry) => entry.mimeType == lowMime; _test = (entry) => entry.mimeType == lowMime;
_label = MimeUtils.displayType(lowMime); _label = MimeUtils.displayType(lowMime);
@ -47,13 +47,28 @@ class MimeFilter extends CollectionFilter {
EntryFilter get test => _test; EntryFilter get test => _test;
@override @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 @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
@override @override
String get typeKey => type; String get category => type;
@override
String get key => '$type-$mime';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -50,7 +50,7 @@ class QueryFilter extends CollectionFilter {
bool get isUnique => false; bool get isUnique => false;
@override @override
String get label => '$query'; String get universalLabel => query;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.text, size: size); 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); Future<Color> color(BuildContext context) => colorful ? super.color(context) : SynchronousFuture(AvesFilterChip.defaultOutlineColor);
@override @override
String get typeKey => type; String get category => type;
@override
String get key => '$type-$query';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -1,11 +1,11 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class TagFilter extends CollectionFilter { class TagFilter extends CollectionFilter {
static const type = 'tag'; static const type = 'tag';
static const emptyLabel = 'untagged';
final String tag; final String tag;
EntryFilter _test; EntryFilter _test;
@ -36,13 +36,19 @@ class TagFilter extends CollectionFilter {
bool get isUnique => false; bool get isUnique => false;
@override @override
String get label => tag.isEmpty ? emptyLabel : tag; String get universalLabel => tag;
@override
String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterTagEmptyLabel : tag;
@override @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null; Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagOff : AIcons.tag, size: size) : null;
@override @override
String get typeKey => type; String get category => type;
@override
String get key => '$type-$tag';
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {

View file

@ -1,5 +1,6 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -13,26 +14,26 @@ class TypeFilter extends CollectionFilter {
final String itemType; final String itemType;
EntryFilter _test; EntryFilter _test;
String _label;
IconData _icon; IconData _icon;
TypeFilter(this.itemType) { TypeFilter(this.itemType) {
if (itemType == animated) { switch (itemType) {
_test = (entry) => entry.isAnimated; case animated:
_label = 'Animated'; _test = (entry) => entry.isAnimated;
_icon = AIcons.animated; _icon = AIcons.animated;
} else if (itemType == panorama) { break;
_test = (entry) => entry.isImage && entry.is360; case panorama:
_label = 'Panorama'; _test = (entry) => entry.isImage && entry.is360;
_icon = AIcons.threesixty; _icon = AIcons.threesixty;
} else if (itemType == sphericalVideo) { break;
_test = (entry) => entry.isVideo && entry.is360; case sphericalVideo:
_label = '360° Video'; _test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threesixty; _icon = AIcons.threesixty;
} else if (itemType == geotiff) { break;
_test = (entry) => entry.isGeotiff; case geotiff:
_label = 'GeoTIFF'; _test = (entry) => entry.isGeotiff;
_icon = AIcons.geo; _icon = AIcons.geo;
break;
} }
} }
@ -51,13 +52,32 @@ class TypeFilter extends CollectionFilter {
EntryFilter get test => _test; EntryFilter get test => _test;
@override @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 @override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size); Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
@override @override
String get typeKey => type; String get category => type;
@override
String get key => '$type-$itemType';
@override @override
bool operator ==(Object other) { 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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:geocoder/model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateMetadata { class DateMetadata {
@ -204,38 +204,3 @@ class AddressDetails {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; 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 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/metadata_db_upgrade.dart'; import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:sqflite/sqflite.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<Database> _database;
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db'); Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
@ -19,9 +89,9 @@ class MetadataDb {
static const metadataTable = 'metadata'; static const metadataTable = 'metadata';
static const addressTable = 'address'; static const addressTable = 'address';
static const favouriteTable = 'favourites'; static const favouriteTable = 'favourites';
static const coverTable = 'covers';
MetadataDb._private(); @override
Future<void> init() async { Future<void> init() async {
debugPrint('$runtimeType init'); debugPrint('$runtimeType init');
_database = openDatabase( _database = openDatabase(
@ -68,17 +138,23 @@ class MetadataDb {
'contentId INTEGER PRIMARY KEY' 'contentId INTEGER PRIMARY KEY'
', path TEXT' ', path TEXT'
')'); ')');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}, },
onUpgrade: MetadataDbUpgrader.upgradeDb, onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 3, version: 4,
); );
} }
@override
Future<int> dbFileSize() async { Future<int> dbFileSize() async {
final file = File((await path)); final file = File((await path));
return await file.exists() ? file.length() : 0; return await file.exists() ? file.length() : 0;
} }
@override
Future<void> reset() async { Future<void> reset() async {
debugPrint('$runtimeType reset'); debugPrint('$runtimeType reset');
await (await _database).close(); await (await _database).close();
@ -86,7 +162,8 @@ class MetadataDb {
await init(); 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; if (contentIds == null || contentIds.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -100,8 +177,9 @@ class MetadataDb {
batch.delete(dateTakenTable, where: where, whereArgs: whereArgs); batch.delete(dateTakenTable, where: where, whereArgs: whereArgs);
batch.delete(metadataTable, where: where, whereArgs: whereArgs); batch.delete(metadataTable, where: where, whereArgs: whereArgs);
batch.delete(addressTable, where: where, whereArgs: whereArgs); batch.delete(addressTable, where: where, whereArgs: whereArgs);
if (updateFavourites) { if (!metadataOnly) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs); batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs);
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
@ -110,12 +188,14 @@ class MetadataDb {
// entries // entries
@override
Future<void> clearEntries() async { Future<void> clearEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(entryTable, where: '1'); final count = await db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count entries'); debugPrint('$runtimeType clearEntries deleted $count entries');
} }
@override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -125,6 +205,7 @@ class MetadataDb {
return entries; return entries;
} }
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) async { Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries == null || entries.isEmpty) return; if (entries == null || entries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -135,6 +216,7 @@ class MetadataDb {
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries'); debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
} }
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) async { Future<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -154,12 +236,14 @@ class MetadataDb {
// date taken // date taken
@override
Future<void> clearDates() async { Future<void> clearDates() async {
final db = await _database; final db = await _database;
final count = await db.delete(dateTakenTable, where: '1'); final count = await db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count entries'); debugPrint('$runtimeType clearDates deleted $count entries');
} }
@override
Future<List<DateMetadata>> loadDates() async { Future<List<DateMetadata>> loadDates() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -171,12 +255,14 @@ class MetadataDb {
// catalog metadata // catalog metadata
@override
Future<void> clearMetadataEntries() async { Future<void> clearMetadataEntries() async {
final db = await _database; final db = await _database;
final count = await db.delete(metadataTable, where: '1'); final count = await db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count entries'); debugPrint('$runtimeType clearMetadataEntries deleted $count entries');
} }
@override
Future<List<CatalogMetadata>> loadMetadataEntries() async { Future<List<CatalogMetadata>> loadMetadataEntries() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -186,6 +272,7 @@ class MetadataDb {
return metadataEntries; return metadataEntries;
} }
@override
Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async { Future<void> saveMetadata(Iterable<CatalogMetadata> metadataEntries) async {
if (metadataEntries == null || metadataEntries.isEmpty) return; if (metadataEntries == null || metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -200,6 +287,7 @@ class MetadataDb {
} }
} }
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async { Future<void> updateMetadataId(int oldId, CatalogMetadata metadata) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -227,12 +315,14 @@ class MetadataDb {
// address // address
@override
Future<void> clearAddresses() async { Future<void> clearAddresses() async {
final db = await _database; final db = await _database;
final count = await db.delete(addressTable, where: '1'); final count = await db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count entries'); debugPrint('$runtimeType clearAddresses deleted $count entries');
} }
@override
Future<List<AddressDetails>> loadAddresses() async { Future<List<AddressDetails>> loadAddresses() async {
// final stopwatch = Stopwatch()..start(); // final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
@ -242,6 +332,7 @@ class MetadataDb {
return addresses; return addresses;
} }
@override
Future<void> saveAddresses(Iterable<AddressDetails> addresses) async { Future<void> saveAddresses(Iterable<AddressDetails> addresses) async {
if (addresses == null || addresses.isEmpty) return; if (addresses == null || addresses.isEmpty) return;
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -252,6 +343,7 @@ class MetadataDb {
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries'); debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
} }
@override
Future<void> updateAddressId(int oldId, AddressDetails address) async { Future<void> updateAddressId(int oldId, AddressDetails address) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -271,31 +363,31 @@ class MetadataDb {
// favourites // favourites
@override
Future<void> clearFavourites() async { Future<void> clearFavourites() async {
final db = await _database; final db = await _database;
final count = await db.delete(favouriteTable, where: '1'); final count = await db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count entries'); debugPrint('$runtimeType clearFavourites deleted $count entries');
} }
Future<List<FavouriteRow>> loadFavourites() async { @override
// final stopwatch = Stopwatch()..start(); Future<Set<FavouriteRow>> loadFavourites() async {
final db = await _database; final db = await _database;
final maps = await db.query(favouriteTable); final maps = await db.query(favouriteTable);
final favouriteRows = maps.map((map) => FavouriteRow.fromMap(map)).toList(); final rows = maps.map((map) => FavouriteRow.fromMap(map)).toSet();
// debugPrint('$runtimeType loadFavourites complete in ${stopwatch.elapsed.inMilliseconds}ms for ${favouriteRows.length} entries'); return rows;
return favouriteRows;
} }
Future<void> addFavourites(Iterable<FavouriteRow> favouriteRows) async { @override
if (favouriteRows == null || favouriteRows.isEmpty) return; Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
// final stopwatch = Stopwatch()..start(); if (rows == null || rows.isEmpty) return;
final db = await _database; final db = await _database;
final batch = db.batch(); 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); 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 { Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database; final db = await _database;
final batch = db.batch(); final batch = db.batch();
@ -313,9 +405,10 @@ class MetadataDb {
); );
} }
Future<void> removeFavourites(Iterable<FavouriteRow> favouriteRows) async { @override
if (favouriteRows == null || favouriteRows.isEmpty) return; Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
final ids = favouriteRows.where((row) => row != null).map((row) => row.contentId); if (rows == null || rows.isEmpty) return;
final ids = rows.where((row) => row != null).map((row) => row.contentId);
if (ids.isEmpty) return; if (ids.isEmpty) return;
final db = await _database; final db = await _database;
@ -324,4 +417,61 @@ class MetadataDb {
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id])); ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
await batch.commit(noResult: true); 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'; import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader { class MetadataDbUpgrader {
static const entryTable = MetadataDb.entryTable; static const entryTable = SqfliteMetadataDb.entryTable;
static const metadataTable = MetadataDb.metadataTable; static const metadataTable = SqfliteMetadataDb.metadataTable;
static const coverTable = SqfliteMetadataDb.coverTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported // warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices // on SQLite <3.25.0, bundled on older Android devices
@ -17,6 +18,9 @@ class MetadataDbUpgrader {
case 2: case 2:
await _upgradeFrom2(db); await _upgradeFrom2(db);
break; break;
case 3:
await _upgradeFrom3(db);
break;
} }
oldVersion++; oldVersion++;
} }
@ -97,4 +101,12 @@ class MetadataDbUpgrader {
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;'); 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; final index = map['page'] as int;
return SinglePageInfo( return SinglePageInfo(
index: index, index: index,
pageId: map['trackId'] as int ?? index, pageId: index,
mimeType: map['mimeType'] as String, mimeType: map['mimeType'] as String,
isDefault: map['isDefault'] as bool ?? false, isDefault: map['isDefault'] as bool ?? false,
width: map['width'] as int ?? 0, width: map['width'] as int ?? 0,

View file

@ -1,15 +1,17 @@
import 'package:aves/geo/format.dart'; 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'; import 'package:latlong/latlong.dart';
enum CoordinateFormat { dms, decimal } import 'enums.dart';
extension ExtraCoordinateFormat on CoordinateFormat { extension ExtraCoordinateFormat on CoordinateFormat {
String get name { String getName(BuildContext context) {
switch (this) { switch (this) {
case CoordinateFormat.dms: case CoordinateFormat.dms:
return 'DMS'; return context.l10n.coordinateFormatDms;
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return 'Decimal degrees'; return context.l10n.coordinateFormatDecimal;
default: default:
return toString(); return toString();
} }

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum EntryBackground { black, white, transparent, checkered } import 'enums.dart';
extension ExtraEntryBackground on EntryBackground { extension ExtraEntryBackground on EntryBackground {
bool get isColor { 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/collection/collection_page.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/filter_grids/albums_page.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 { extension ExtraHomePageSetting on HomePageSetting {
String get name { String getName(BuildContext context) {
switch (this) { switch (this) {
case HomePageSetting.collection: case HomePageSetting.collection:
return 'Collection'; return context.l10n.collectionPageTitle;
case HomePageSetting.albums: case HomePageSetting.albums:
return 'Albums'; return context.l10n.albumPageTitle;
default: default:
return toString(); return toString();
} }

View file

@ -1,21 +1,23 @@
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ import 'package:aves/widgets/common/extensions/build_context.dart';
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraEntryMapStyle on EntryMapStyle { extension ExtraEntryMapStyle on EntryMapStyle {
String get name { String getName(BuildContext context) {
switch (this) { switch (this) {
case EntryMapStyle.googleNormal: case EntryMapStyle.googleNormal:
return 'Google Maps'; return context.l10n.mapStyleGoogleNormal;
case EntryMapStyle.googleHybrid: case EntryMapStyle.googleHybrid:
return 'Google Maps (Hybrid)'; return context.l10n.mapStyleGoogleHybrid;
case EntryMapStyle.googleTerrain: case EntryMapStyle.googleTerrain:
return 'Google Maps (Terrain)'; return context.l10n.mapStyleGoogleTerrain;
case EntryMapStyle.osmHot: case EntryMapStyle.osmHot:
return 'Humanitarian OSM'; return context.l10n.mapStyleOsmHot;
case EntryMapStyle.stamenToner: case EntryMapStyle.stamenToner:
return 'Stamen Toner'; return context.l10n.mapStyleStamenToner;
case EntryMapStyle.stamenWatercolor: case EntryMapStyle.stamenWatercolor:
return 'Stamen Watercolor'; return context.l10n.mapStyleStamenWatercolor;
default: default:
return toString(); return toString();
} }

View file

@ -1,16 +1,18 @@
import 'package:aves/services/window_service.dart'; 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 { extension ExtraKeepScreenOn on KeepScreenOn {
String get name { String getName(BuildContext context) {
switch (this) { switch (this) {
case KeepScreenOn.never: case KeepScreenOn.never:
return 'Never'; return context.l10n.keepScreenOnNever;
case KeepScreenOn.viewerOnly: case KeepScreenOn.viewerOnly:
return 'Viewer page only'; return context.l10n.keepScreenOnViewerOnly;
case KeepScreenOn.always: case KeepScreenOn.always:
return 'Always'; return context.l10n.keepScreenOnAlways;
default: default:
return toString(); return toString();
} }

View file

@ -1,18 +1,14 @@
import 'package:aves/model/filters/filters.dart'; 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:aves/model/settings/screen_on.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart'; import 'package:pedantic/pedantic.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../source/enums.dart'; import '../source/enums.dart';
import 'enums.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
@ -24,6 +20,7 @@ class Settings extends ChangeNotifier {
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled'; static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
static const localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on'; static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page'; static const homePageKey = 'home_page';
@ -99,6 +96,34 @@ class Settings extends ChangeNotifier {
unawaited(initFirebase()); 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); bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, true);
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue); set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
@ -120,7 +145,7 @@ class Settings extends ChangeNotifier {
double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0; 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 // and should not trigger rebuilding by change notification
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false); 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); 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); 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/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
mixin AlbumMixin on SourceBase { mixin AlbumMixin on SourceBase {
@ -12,8 +13,8 @@ mixin AlbumMixin on SourceBase {
List<String> get rawAlbums => List.unmodifiable(_directories); List<String> get rawAlbums => List.unmodifiable(_directories);
int compareAlbumsByName(String a, String b) { int compareAlbumsByName(String a, String b) {
final ua = getUniqueAlbumName(a); final ua = getUniqueAlbumName(null, a);
final ub = getUniqueAlbumName(b); final ub = getUniqueAlbumName(null, b);
final c = compareAsciiUpperCase(ua, ub); final c = compareAsciiUpperCase(ua, ub);
if (c != 0) return c; if (c != 0) return c;
final va = androidFileUtils.getStorageVolume(a)?.path ?? ''; final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
@ -23,7 +24,7 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getUniqueAlbumName(String dirPath) { String getUniqueAlbumName(BuildContext context, String dirPath) {
String unique(String dirPath, [bool Function(String) test]) { String unique(String dirPath, [bool Function(String) test]) {
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath); final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
final parts = dirPath.split(separator); final parts = dirPath.split(separator);
@ -51,7 +52,7 @@ mixin AlbumMixin on SourceBase {
if (volume.isPrimary) { if (volume.isPrimary) {
return uniqueNameInVolume; return uniqueNameInVolume;
} else { } else {
return '$uniqueNameInVolume (${volume.description})'; return '$uniqueNameInVolume (${volume.getDescription(context)})';
} }
} }
} }
@ -99,7 +100,7 @@ mixin AlbumMixin on SourceBase {
invalidateAlbumFilterSummary(directories: emptyAlbums); invalidateAlbumFilterSummary(directories: emptyAlbums);
final pinnedFilters = settings.pinnedFilters; 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; settings.pinnedFilters = pinnedFilters;
} }
} }

View file

@ -92,7 +92,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
void addFilter(CollectionFilter filter) { void addFilter(CollectionFilter filter) {
if (filter == null || filters.contains(filter)) return; if (filter == null || filters.contains(filter)) return;
if (filter.isUnique) { if (filter.isUnique) {
filters.removeWhere((old) => old.typeKey == filter.typeKey); filters.removeWhere((old) => old.category == filter.category);
} }
filters.add(filter); filters.add(filter);
onFilterChanged(); onFilterChanged();

View file

@ -1,18 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.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/settings/settings.dart';
import 'package:aves/model/source/album.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/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -98,10 +100,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent(entries)); eventBus.fire(EntryAddedEvent(entries));
} }
void removeEntries(Set<String> uris) { Future<void> removeEntries(Set<String> uris) async {
if (uris.isEmpty) return; if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet(); 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); _rawEntries.removeAll(entries);
_invalidate(entries); _invalidate(entries);
@ -120,30 +123,61 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateTags(); updateTags();
} }
Future<void> _moveEntry(AvesEntry entry, Map newFields, bool isFavourite) async { Future<void> _moveEntry(AvesEntry entry, Map newFields) async {
final oldContentId = entry.contentId; final oldContentId = entry.contentId;
final newContentId = newFields['contentId'] as int; final newContentId = newFields['contentId'] as int;
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.contentId = newContentId;
// `dateModifiedSecs` changes when moving entries to another directory, // `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory // but it does not change when renaming the containing directory
if (newDateModifiedSecs != null) entry.dateModifiedSecs = newDateModifiedSecs; if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int;
entry.path = newFields['path'] as String; if (newFields.containsKey('path')) entry.path = newFields['path'] as String;
entry.uri = newFields['uri'] as String; if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
entry.contentId = newContentId; if (newFields.containsKey('title') != null) entry.sourceTitle = newFields['title'] as String;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId); entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId); entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await metadataDb.updateEntryId(oldContentId, entry); await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata); await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails); await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
if (isFavourite) { await favourites.moveEntry(oldContentId, entry);
await favourites.move(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> todoEntries,
@required Set<AvesEntry> favouriteEntries,
@required bool copy, @required bool copy,
@required String destinationAlbum, @required String destinationAlbum,
@required Set<MoveOpEvent> movedOps, @required Set<MoveOpEvent> movedOps,
@ -177,10 +211,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (entry != null) { if (entry != null) {
fromAlbums.add(entry.directory); fromAlbums.add(entry.directory);
movedEntries.add(entry); movedEntries.add(entry);
// do not rely on current favourite repo state to assess whether the moved entry is a favourite await _moveEntry(entry, newFields);
// as source monitoring may already have removed the entry from the favourite repo
final isFavourite = favouriteEntries.contains(entry);
await _moveEntry(entry, newFields, isFavourite);
} }
} }
}); });
@ -231,6 +262,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return null; 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) { void changeFilterVisibility(CollectionFilter filter, bool visible) {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
if (visible) { if (visible) {
@ -256,8 +296,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
} }
} }
enum SourceState { loading, cataloguing, locating, ready }
class EntryAddedEvent { class EntryAddedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;

View file

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

View file

@ -1,12 +1,12 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/geo/countries.dart'; import 'package:aves/geo/countries.dart';
import 'package:aves/model/availability.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata.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/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.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) // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
final latLngFactor = pow(10, 2); final latLngFactor = pow(10, 2);
Tuple2 approximateLatLng(AvesEntry entry) { Tuple2<int, int> approximateLatLng(AvesEntry entry) {
final lat = entry.catalogMetadata?.latitude; final lat = entry.catalogMetadata?.latitude;
final lng = entry.catalogMetadata?.longitude; final lng = entry.catalogMetadata?.longitude;
if (lat == null || lng == null) return null; 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)); byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locating;
@ -138,7 +138,7 @@ mixin LocationMixin on SourceBase {
} }
// the same country code could be found with different country names // 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 // 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 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); 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:async';
import 'dart:math'; import 'dart:math';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/time_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
@ -26,7 +25,8 @@ class MediaStoreSource extends CollectionSource {
stateNotifier.value = SourceState.loading; stateNotifier.value = SourceState.loading;
await metadataDb.init(); await metadataDb.init();
await favourites.init(); await favourites.init();
final currentTimeZone = await TimeService.getDefaultTimeZone(); await covers.init();
final currentTimeZone = await timeService.getDefaultTimeZone();
final catalogTimeZone = settings.catalogTimeZone; final catalogTimeZone = settings.catalogTimeZone;
if (currentTimeZone != catalogTimeZone) { if (currentTimeZone != catalogTimeZone) {
// clear catalog metadata to get correct date/times when moving to a different time zone // 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 oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs))); final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final obsoleteContentIds = (await MediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet(); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId)); oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
// show known entries // show known entries
@ -60,11 +60,11 @@ class MediaStoreSource extends CollectionSource {
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
// clean up obsolete entries // 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` // 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 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) { movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date // make obsolete by resetting its modified date
knownDateById[contentId] = 0; knownDateById[contentId] = 0;
@ -81,7 +81,7 @@ class MediaStoreSource extends CollectionSource {
pendingNewEntries.clear(); pendingNewEntries.clear();
} }
MediaStoreService.getEntries(knownDateById).listen( mediaStoreService.getEntries(knownDateById).listen(
(entry) { (entry) {
pendingNewEntries.add(entry); pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) { if (pendingNewEntries.length >= refreshCount) {
@ -114,6 +114,7 @@ class MediaStoreSource extends CollectionSource {
} }
void _reportCollectionDimensions() { void _reportCollectionDimensions() {
if (!settings.isCrashlyticsEnabled) return;
final analytics = FirebaseAnalytics(); final analytics = FirebaseAnalytics();
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()); analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()); analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
@ -141,9 +142,9 @@ class MediaStoreSource extends CollectionSource {
}).where((kv) => kv != null)); }).where((kv) => kv != null));
// clean up obsolete entries // 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(); final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).toSet();
removeEntries(obsoleteUris); await removeEntries(obsoleteUris);
obsoleteContentIds.forEach(uriByContentId.remove); obsoleteContentIds.forEach(uriByContentId.remove);
// fetch new entries // fetch new entries
@ -153,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
for (final kv in uriByContentId.entries) { for (final kv in uriByContentId.entries) {
final contentId = kv.key; final contentId = kv.key;
final uri = kv.value; final uri = kv.value;
final sourceEntry = await ImageFileService.getEntry(uri, null); final sourceEntry = await imageFileService.getEntry(uri, null);
if (sourceEntry != null) { if (sourceEntry != null) {
final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null); final existingEntry = allEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
// compare paths because some apps move files without updating their `last modified date` // compare paths because some apps move files without updating their `last modified date`
@ -188,7 +189,7 @@ class MediaStoreSource extends CollectionSource {
@override @override
Future<void> refreshMetadata(Set<AvesEntry> entries) { Future<void> refreshMetadata(Set<AvesEntry> entries) {
final contentIds = entries.map((entry) => entry.contentId).toSet(); final contentIds = entries.map((entry) => entry.contentId).toSet();
metadataDb.removeIds(contentIds, updateFavourites: false); metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh(); return refresh();
} }
} }

View file

@ -1,8 +1,9 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata.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/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View file

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

View file

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.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/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -30,7 +30,7 @@ class AppShortcutService {
Uint8List iconBytes; Uint8List iconBytes;
if (entry != null) { if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0; final size = entry.isVideo ? 0.0 : 256.0;
iconBytes = await ImageFileService.getThumbnail( iconBytes = await imageFileService.getThumbnail(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
pageId: entry.pageId, 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:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.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 const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream'); static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/imageopstream'); 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 { try {
final result = await platform.invokeMethod('getEntry', <String, dynamic>{ final result = await platform.invokeMethod('getEntry', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -44,7 +120,8 @@ class ImageFileService {
return null; return null;
} }
static Future<Uint8List> getSvg( @override
Future<Uint8List> getSvg(
String uri, String uri,
String mimeType, { String mimeType, {
int expectedContentLength, int expectedContentLength,
@ -59,7 +136,8 @@ class ImageFileService {
onBytesReceived: onBytesReceived, onBytesReceived: onBytesReceived,
); );
static Future<Uint8List> getImage( @override
Future<Uint8List> getImage(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -106,8 +184,8 @@ class ImageFileService {
return Future.sync(() => null); return Future.sync(() => null);
} }
// `rect`: region to decode, with coordinates in reference to `imageSize` @override
static Future<Uint8List> getRegion( Future<Uint8List> getRegion(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -145,7 +223,8 @@ class ImageFileService {
); );
} }
static Future<Uint8List> getThumbnail({ @override
Future<Uint8List> getThumbnail({
@required String uri, @required String uri,
@required String mimeType, @required String mimeType,
@required int rotationDegrees, @required int rotationDegrees,
@ -184,7 +263,8 @@ class ImageFileService {
); );
} }
static Future<void> clearSizedThumbnailDiskCache() async { @override
Future<void> clearSizedThumbnailDiskCache() async {
try { try {
return platform.invokeMethod('clearSizedThumbnailDiskCache'); return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) { } 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 { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'delete', 'op': 'delete',
@ -210,7 +294,8 @@ class ImageFileService {
} }
} }
static Stream<MoveOpEvent> move( @override
Stream<MoveOpEvent> move(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
@required bool copy, @required bool copy,
@required String destinationAlbum, @required String destinationAlbum,
@ -228,7 +313,8 @@ class ImageFileService {
} }
} }
static Stream<ExportOpEvent> export( @override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
String mimeType = MimeTypes.jpeg, String mimeType = MimeTypes.jpeg,
@required String destinationAlbum, @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 { try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional) // returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{ final result = await platform.invokeMethod('rename', <String, dynamic>{
@ -260,7 +347,8 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async { @override
Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('rotate', <String, dynamic>{ final result = await platform.invokeMethod('rotate', <String, dynamic>{
@ -274,7 +362,8 @@ class ImageFileService {
return {}; return {};
} }
static Future<Map> flip(AvesEntry entry) async { @override
Future<Map> flip(AvesEntry entry) async {
try { try {
// returns map with: 'rotationDegrees' 'isFlipped' // returns map with: 'rotationDegrees' 'isFlipped'
final result = await platform.invokeMethod('flip', <String, dynamic>{ 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:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.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 const platform = MethodChannel('deckers.thibault/aves/mediastore');
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream'); 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 { try {
final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{ final result = await platform.invokeMethod('checkObsoleteContentIds', <String, dynamic>{
'knownContentIds': knownContentIds, 'knownContentIds': knownContentIds,
@ -21,7 +31,8 @@ class MediaStoreService {
return []; return [];
} }
static Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async { @override
Future<List<int>> checkObsoletePaths(Map<int, String> knownPathById) async {
try { try {
final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{ final result = await platform.invokeMethod('checkObsoletePaths', <String, dynamic>{
'knownPathById': knownPathById, 'knownPathById': knownPathById,
@ -33,8 +44,8 @@ class MediaStoreService {
return []; return [];
} }
// knownEntries: map of contentId -> dateModifiedSecs @override
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) { Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
try { try {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{ return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries, 'knownEntries': knownEntries,

View file

@ -8,11 +8,32 @@ import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.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'); 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) @override
static Future<Map> getAllMetadata(AvesEntry entry) async { Future<Map> getAllMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -28,7 +49,8 @@ class MetadataService {
return {}; 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; if (entry.isSvg) return null;
Future<CatalogMetadata> call() async { Future<CatalogMetadata> call() async {
@ -65,7 +87,8 @@ class MetadataService {
: call(); : call();
} }
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async { @override
Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
if (entry.isSvg) return null; if (entry.isSvg) return null;
try { try {
@ -82,7 +105,8 @@ class MetadataService {
return null; return null;
} }
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async { @override
Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{ final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -96,7 +120,8 @@ class MetadataService {
return null; return null;
} }
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async { @override
Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
try { try {
// returns map with values for: // returns map with values for:
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int), // 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
@ -113,7 +138,8 @@ class MetadataService {
return null; return null;
} }
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async { @override
Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
try { try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{ return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -126,7 +152,8 @@ class MetadataService {
return null; return null;
} }
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async { @override
Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -138,7 +165,8 @@ class MetadataService {
return []; return [];
} }
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async { @override
Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
try { try {
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{ final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
'mimeType': entry.mimeType, 'mimeType': entry.mimeType,
@ -152,7 +180,8 @@ class MetadataService {
return []; return [];
} }
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async { @override
Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
try { try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{ final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'mimeType': entry.mimeType, '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 'dart:convert';
import 'package:aves/model/entry.dart'; 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:aves/utils/string_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,7 +17,7 @@ class SvgMetadataService {
static Future<Size> getSize(AvesEntry entry) async { static Future<Size> getSize(AvesEntry entry) async {
try { 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 document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;
@ -59,7 +59,7 @@ class SvgMetadataService {
} }
try { 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 document = XmlDocument.parse(utf8.decode(data));
final root = document.rootElement; final root = document.rootElement;

View file

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

View file

@ -3,7 +3,6 @@ import 'package:flutter/scheduler.dart';
class Durations { class Durations {
// common animations // common animations
static const iconAnimation = Duration(milliseconds: 300); static const iconAnimation = Duration(milliseconds: 300);
static const opToastAnimation = Duration(milliseconds: 600);
static const sweeperOpacityAnimation = Duration(milliseconds: 150); static const sweeperOpacityAnimation = Duration(milliseconds: 150);
static const sweepingAnimation = Duration(milliseconds: 650); static const sweepingAnimation = Duration(milliseconds: 650);
static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration static const popupMenuAnimation = Duration(milliseconds: 300); // ref _PopupMenuRoute._kMenuDuration
@ -43,7 +42,7 @@ class Durations {
static const xmpStructArrayCardTransition = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300);
// delays & refresh intervals // delays & refresh intervals
static const opToastDisplay = Duration(seconds: 2); static const opToastDisplay = Duration(seconds: 3);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const highlightScrollInitDelay = Duration(milliseconds: 800); 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 openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined; static const IconData pin = Icons.push_pin_outlined;
static const IconData print = Icons.print_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 rename = Icons.title_outlined;
static const IconData rotateLeft = Icons.rotate_left_outlined; static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined; static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData search = Icons.search_outlined; static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_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 share = Icons.share_outlined;
static const IconData sort = Icons.sort_outlined; static const IconData sort = Icons.sort_outlined;
static const IconData stats = Icons.pie_chart_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_app_service.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/change_notifier.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/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
@ -115,21 +116,30 @@ class Package {
@immutable @immutable
class StorageVolume { class StorageVolume {
final String description, path, state; final String _description, path, state;
final bool isPrimary, isRemovable; final bool isPrimary, isRemovable;
const StorageVolume({ const StorageVolume({
this.description, String description,
this.isPrimary, this.isPrimary,
this.isRemovable, this.isRemovable,
this.path, this.path,
this.state, 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) { factory StorageVolume.fromMap(Map map) {
final isPrimary = map['isPrimary'] ?? false; final isPrimary = map['isPrimary'] ?? false;
return StorageVolume( return StorageVolume(
description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'), description: map['description'],
isPrimary: isPrimary, isPrimary: isPrimary,
isRemovable: map['isRemovable'] ?? false, isRemovable: map['isRemovable'] ?? false,
path: map['path'] ?? '', path: map['path'] ?? '',
@ -167,11 +177,9 @@ class VolumeRelativeDirectory {
); );
} }
String get directoryDescription => relativeDir.isEmpty ? 'root' : '$relativeDir'; String getVolumeDescription(BuildContext context) {
String get volumeDescription {
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null); final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
return volume?.description ?? volumePath; return volume?.getDescription(context) ?? volumePath;
} }
@override @override

View file

@ -21,7 +21,6 @@ class Constants {
); );
static const overlayUnknown = ''; // em dash static const overlayUnknown = ''; // em dash
static const infoUnknown = 'unknown';
static final pointNemo = LatLng(-48.876667, -123.393333); static final pointNemo = LatLng(-48.876667, -123.393333);
@ -66,55 +65,13 @@ class Constants {
), ),
]; ];
static const List<Dependency> flutterPackages = [ static const List<Dependency> flutterPlugins = [
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',
),
Dependency( Dependency(
name: 'Connectivity', name: 'Connectivity',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity', 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( Dependency(
name: 'FlutterFire (Core, Analytics, Crashlytics)', name: 'FlutterFire (Core, Analytics, Crashlytics)',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
@ -122,10 +79,160 @@ class Constants {
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
), ),
Dependency( 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', license: 'Apache 2.0',
licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE', licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
sourceUrl: 'https://github.com/AndreHaueisen/flushbar', 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( Dependency(
name: 'Flutter Highlight', name: 'Flutter Highlight',
@ -134,10 +241,10 @@ class Constants {
sourceUrl: 'https://github.com/git-touch/highlight', sourceUrl: 'https://github.com/git-touch/highlight',
), ),
Dependency( Dependency(
name: 'Flutter ijkplayer', name: 'Flutter Localized Locales',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer/blob/master/LICENSE', licenseUrl: 'https://github.com/guidezpl/flutter-localized-locales/blob/master/LICENSE',
sourceUrl: 'https://github.com/CaiJingLong/flutter_ijkplayer', sourceUrl: 'https://github.com/guidezpl/flutter-localized-locales',
), ),
Dependency( Dependency(
name: 'Flutter Map', name: 'Flutter Map',
@ -163,36 +270,6 @@ class Constants {
licenseUrl: 'https://github.com/dnfield/flutter_svg/blob/master/LICENSE', licenseUrl: 'https://github.com/dnfield/flutter_svg/blob/master/LICENSE',
sourceUrl: 'https://github.com/dnfield/flutter_svg', 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( Dependency(
name: 'Material Design Icons Flutter', name: 'Material Design Icons Flutter',
license: 'MIT', license: 'MIT',
@ -205,12 +282,6 @@ class Constants {
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE', licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support', 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( Dependency(
name: 'Palette Generator', name: 'Palette Generator',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
@ -223,84 +294,18 @@ class Constants {
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE', licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama', 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( Dependency(
name: 'Percent Indicator', name: 'Percent Indicator',
license: 'BSD 2-Clause', license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE', licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE',
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/', 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( Dependency(
name: 'Provider', name: 'Provider',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE', licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider', 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/app_ref.dart';
import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/credits.dart';
import 'package:aves/widgets/about/licenses.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'; import 'package:flutter/material.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
@ -11,7 +12,7 @@ class AboutPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('About'), title: Text(context.l10n.aboutPageTitle),
), ),
body: SafeArea( body: SafeArea(
child: CustomScrollView( child: CustomScrollView(
@ -23,7 +24,7 @@ class AboutPage extends StatelessWidget {
[ [
AppReference(), AppReference(),
Divider(), Divider(),
AboutNewVersion(), AboutUpdate(),
AboutCredits(), AboutCredits(),
Divider(), Divider(),
], ],

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