Merge branch 'develop'
This commit is contained in:
commit
9b74dd289e
139 changed files with 2320 additions and 984 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.3.5] - 2021-02-26
|
||||
### Added
|
||||
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
|
||||
- quick country reverse geocoding without Play Services
|
||||
- menu option to hide any filter
|
||||
- menu option to navigate to the album / country / tag page from filter
|
||||
|
||||
### Changed
|
||||
- analytics are opt-in
|
||||
|
||||
### Removed
|
||||
- removed custom font used in titles and info page
|
||||
|
||||
## [v1.3.4] - 2021-02-10
|
||||
### Added
|
||||
- hide album / country / tag from collection
|
||||
|
|
|
@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
|
||||
- favorites
|
||||
- statistics
|
||||
- support Android API 24 ~ 30 (Nougat ~ R)
|
||||
- support Android API 19 ~ 30 (KitKat ~ R)
|
||||
- Android integration (app shortcuts, handle view/pick intents)
|
||||
|
||||
## Known Issues
|
||||
|
|
|
@ -53,8 +53,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
// TODO TLAD try minSdkVersion 23
|
||||
minSdkVersion 24
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
|
|
@ -14,10 +14,6 @@
|
|||
https://developer.android.com/preview/privacy/storage#media-file-access
|
||||
- raw path access:
|
||||
https://developer.android.com/preview/privacy/storage#media-files-raw-paths
|
||||
|
||||
Android R issues:
|
||||
- users cannot grant directory access to the root Downloads directory,
|
||||
- users cannot grant directory access to the root directory of each reliable SD card volume
|
||||
-->
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
@ -31,9 +27,7 @@
|
|||
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<!-- TODO TLAD remove this permission once this issue is fixed:
|
||||
https://github.com/flutter/flutter/issues/42451
|
||||
-->
|
||||
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
|
||||
|
@ -48,6 +42,7 @@
|
|||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
|
|
@ -32,11 +32,13 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
|
||||
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.content.ContentResolver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
|
@ -89,7 +88,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
||||
englishLabel = resources.getString(labelRes)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
|
||||
}
|
||||
englishLabel
|
||||
|
@ -145,7 +144,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||
}
|
||||
Glide.with(context).clear(target)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -50,14 +50,23 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getContextDirs() = hashMapOf(
|
||||
"dataDir" to context.dataDir,
|
||||
"cacheDir" to context.cacheDir,
|
||||
"codeCacheDir" to context.codeCacheDir,
|
||||
"filesDir" to context.filesDir,
|
||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
||||
"obbDir" to context.obbDir,
|
||||
"externalCacheDir" to context.externalCacheDir,
|
||||
).mapValues { it.value?.path }
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
putAll(
|
||||
hashMapOf(
|
||||
"codeCacheDir" to context.codeCacheDir,
|
||||
"noBackupFilesDir" to context.noBackupFilesDir,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
put("dataDir", context.dataDir)
|
||||
}
|
||||
}.mapValues { it.value?.path }
|
||||
|
||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
|
|
@ -125,7 +125,8 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
pageId = pageId,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageSize = Size(imageWidth, imageHeight),
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
result = result,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -119,7 +119,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// optional parent to distinguish child directories of the same type
|
||||
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
||||
|
||||
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
|
||||
val dirMap = metadataMap[dirName] ?: HashMap()
|
||||
metadataMap[dirName] = dirMap
|
||||
|
||||
// tags
|
||||
|
@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
|
||||
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
|
||||
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
|
||||
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR)
|
||||
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
|
||||
}
|
||||
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
|
||||
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
|
||||
|
@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// XMP fallback to IPTC
|
||||
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
|
||||
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
|
||||
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) }
|
||||
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -594,7 +594,9 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
KEY_MIME_TYPE to trackMime,
|
||||
)
|
||||
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
|
||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||
}
|
||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||
if (isVideo(trackMime)) {
|
||||
|
@ -677,26 +679,35 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val projection = arrayOf(prop)
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
var value: Any? = null
|
||||
try {
|
||||
value = when (cursor.getType(0)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||
}
|
||||
cursor.close()
|
||||
result.success(value?.toString())
|
||||
} else {
|
||||
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
|
||||
val cursor: Cursor?
|
||||
try {
|
||||
cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||
} catch (e: Exception) {
|
||||
// throws SQLiteException when the requested prop is not a known column
|
||||
result.error("getContentResolverProp-query", "failed to query for contentUri=$contentUri", e.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
result.error("getContentResolverProp-cursor", "failed to get cursor for contentUri=$contentUri", null)
|
||||
return
|
||||
}
|
||||
|
||||
var value: Any? = null
|
||||
try {
|
||||
value = when (cursor.getType(0)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||
}
|
||||
cursor.close()
|
||||
result.success(value?.toString())
|
||||
}
|
||||
|
||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -4,9 +4,12 @@ import android.content.Context
|
|||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import androidx.core.os.EnvironmentCompat
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
|
||||
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
@ -24,6 +27,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
|
||||
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
|
||||
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
|
@ -31,31 +35,52 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
sm.getStorageVolume(File(volumePath))?.let {
|
||||
val volumeMap = HashMap<String, Any>()
|
||||
volumeMap["path"] = volumePath
|
||||
volumeMap["description"] = it.getDescription(context)
|
||||
volumeMap["isPrimary"] = it.isPrimary
|
||||
volumeMap["isRemovable"] = it.isRemovable
|
||||
volumeMap["isEmulated"] = it.isEmulated
|
||||
volumeMap["state"] = it.state
|
||||
volumes.add(volumeMap)
|
||||
volumes.add(
|
||||
hashMapOf(
|
||||
"path" to volumePath,
|
||||
"description" to it.getDescription(context),
|
||||
"isPrimary" to it.isPrimary,
|
||||
"isRemovable" to it.isRemovable,
|
||||
"state" to it.state,
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
volumes
|
||||
} else {
|
||||
// TODO TLAD find alternative for Android <N
|
||||
emptyList()
|
||||
val primaryVolumePath = getPrimaryVolumePath(context)
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
val volumeFile = File(volumePath)
|
||||
try {
|
||||
val isPrimary = volumePath == primaryVolumePath
|
||||
val isRemovable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
Environment.isExternalStorageRemovable(volumeFile)
|
||||
} else {
|
||||
// random guess
|
||||
!isPrimary
|
||||
}
|
||||
volumes.add(
|
||||
hashMapOf(
|
||||
"path" to volumePath,
|
||||
"isPrimary" to isPrimary,
|
||||
"isRemovable" to isRemovable,
|
||||
"state" to EnvironmentCompat.getStorageState(volumeFile)
|
||||
)
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
result.success(volumes)
|
||||
}
|
||||
|
@ -67,21 +92,9 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm == null) {
|
||||
result.error("getFreeSpace-sm", "failed because of missing Storage Manager", null)
|
||||
return
|
||||
}
|
||||
|
||||
val file = File(path)
|
||||
val volume = sm.getStorageVolume(file)
|
||||
if (volume == null) {
|
||||
result.error("getFreeSpace-volume", "failed because of missing volume for path=$path", null)
|
||||
return
|
||||
}
|
||||
|
||||
// `StorageStatsManager` `getFreeBytes()` is only available from API 26,
|
||||
// and non-primary volume UUIDs cannot be used with it
|
||||
val file = File(path)
|
||||
try {
|
||||
result.success(file.freeSpace)
|
||||
} catch (e: SecurityException) {
|
||||
|
@ -100,8 +113,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths)
|
||||
result.success(dirs)
|
||||
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
|
||||
}
|
||||
|
||||
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(PermissionManager.getRestrictedDirectories(context))
|
||||
}
|
||||
|
||||
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
@ -111,6 +127,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
result.error("revokeDirectoryAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
|
||||
return
|
||||
}
|
||||
|
||||
val success = PermissionManager.revokeDirectoryAccess(context, path)
|
||||
result.success(success)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import java.util.*
|
||||
|
||||
class TimeHandler : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/time"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.WindowManager
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
||||
class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
|
||||
val on = call.argument<Boolean>("on")
|
||||
if (on == null) {
|
||||
result.error("keepOn-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val window = activity.window
|
||||
val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
if (on) {
|
||||
window.addFlags(flag)
|
||||
} else {
|
||||
window.clearFlags(flag)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/window"
|
||||
}
|
||||
}
|
|
@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
|
|||
import android.graphics.BitmapRegionDecoder
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
|
@ -37,7 +36,8 @@ class RegionFetcher internal constructor(
|
|||
pageId: Int?,
|
||||
sampleSize: Int,
|
||||
regionRect: Rect,
|
||||
imageSize: Size,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
||||
|
@ -48,7 +48,8 @@ class RegionFetcher internal constructor(
|
|||
pageId = null,
|
||||
sampleSize = sampleSize,
|
||||
regionRect = regionRect,
|
||||
imageSize = imageSize,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
result = result,
|
||||
)
|
||||
return
|
||||
|
@ -79,9 +80,9 @@ class RegionFetcher internal constructor(
|
|||
|
||||
// with raw images, the known image size may not match the decoded image size
|
||||
// so we scale the requested region accordingly
|
||||
val effectiveRect = if (imageSize.width != decoder.width || imageSize.height != decoder.height) {
|
||||
val xf = decoder.width.toDouble() / imageSize.width
|
||||
val yf = decoder.height.toDouble() / imageSize.height
|
||||
val effectiveRect = if (imageWidth != decoder.width || imageHeight != decoder.height) {
|
||||
val xf = decoder.width.toDouble() / imageWidth
|
||||
val yf = decoder.height.toDouble() / imageHeight
|
||||
Rect(
|
||||
(regionRect.left * xf).roundToInt(),
|
||||
(regionRect.top * yf).roundToInt(),
|
||||
|
|
|
@ -47,7 +47,6 @@ class ThumbnailFetcher internal constructor(
|
|||
|
||||
fun fetch() {
|
||||
var bitmap: Bitmap? = null
|
||||
var recycle = true
|
||||
var exception: Exception? = null
|
||||
|
||||
try {
|
||||
|
@ -66,14 +65,13 @@ class ThumbnailFetcher internal constructor(
|
|||
if (bitmap == null) {
|
||||
try {
|
||||
bitmap = getByGlide()
|
||||
recycle = false
|
||||
} catch (e: Exception) {
|
||||
exception = e
|
||||
}
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
|
||||
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
|
||||
} else {
|
||||
var errorDetails: String? = exception?.message
|
||||
if (errorDetails?.isNotEmpty() == true) {
|
||||
|
|
|
@ -55,7 +55,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAll() {
|
||||
private fun fetchAll() {
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
|
||||
endOfStream()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
@ -26,6 +27,16 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
if (path == null) {
|
||||
error("requestVolumeAccess-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null)
|
||||
return
|
||||
}
|
||||
|
||||
requestVolumeAccess(activity, path!!, { success(true) }, { success(false) })
|
||||
}
|
||||
|
||||
|
@ -42,6 +53,17 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
endOfStream()
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
|
||||
handler.post {
|
||||
try {
|
||||
eventSink.error(errorCode, errorMessage, errorDetails)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun endOfStream() {
|
||||
handler.post {
|
||||
try {
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.bumptech.glide.annotation.GlideModule
|
|||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||
import com.bumptech.glide.module.AppGlideModule
|
||||
import deckers.thibault.aves.utils.compatRemoveIf
|
||||
|
||||
@GlideModule
|
||||
class AvesAppGlideModule : AppGlideModule() {
|
||||
|
@ -20,7 +21,7 @@ class AvesAppGlideModule : AppGlideModule() {
|
|||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||
// prevent ExifInterface error logs
|
||||
// cf https://github.com/bumptech/glide/issues/3383
|
||||
glide.registry.imageHeaderParsers.removeIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
|
||||
glide.registry.imageHeaderParsers.compatRemoveIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
|
||||
}
|
||||
|
||||
override fun isManifestParsingEnabled(): Boolean = false
|
||||
|
|
|
@ -9,6 +9,8 @@ import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
|
|||
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
|
||||
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
|
@ -16,6 +18,7 @@ import kotlin.math.roundToLong
|
|||
|
||||
object ExifInterfaceHelper {
|
||||
private val LOG_TAG = LogUtils.createTag(ExifInterfaceHelper::class.java)
|
||||
private val DATETIME_FORMAT = SimpleDateFormat("yyyy:MM:dd hh:mm:ss", Locale.ROOT)
|
||||
|
||||
private const val precisionErrorTolerance = 1e-10
|
||||
|
||||
|
@ -358,11 +361,15 @@ object ExifInterfaceHelper {
|
|||
|
||||
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
|
||||
if (this.hasAttribute(tag)) {
|
||||
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long
|
||||
val formattedDate = this.getAttribute(tag)
|
||||
val value = formattedDate?.toLongOrNull()
|
||||
if (value != null && value > 0) {
|
||||
save(value)
|
||||
val dateString = this.getAttribute(tag)
|
||||
if (dateString != null) {
|
||||
try {
|
||||
DATETIME_FORMAT.parse(dateString)?.let { date ->
|
||||
save(date.time)
|
||||
}
|
||||
} catch (e: ParseException) {
|
||||
Log.w(LOG_TAG, "failed to parse date=$dateString", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,11 +15,7 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
|
||||
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
|
||||
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
|
||||
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
|
||||
|
@ -38,6 +34,9 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
||||
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
putAll(
|
||||
hashMapOf(
|
||||
|
@ -59,6 +58,15 @@ object MediaMetadataRetrieverHelper {
|
|||
)
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putAll(
|
||||
hashMapOf(
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val durationFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
|
|
|
@ -95,7 +95,7 @@ object Metadata {
|
|||
// opening large TIFF files yields an OOM (both with `metadata-extractor` v2.15.0 and `ExifInterface` v1.3.1),
|
||||
// so we define an arbitrary threshold to avoid a crash on launch.
|
||||
// It is not clear whether it is because of the file itself or its metadata.
|
||||
const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
private const val tiffSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
|
||||
// we try and read metadata from large files by copying an arbitrary amount from its beginning
|
||||
// to a temporary file, and reusing that preview file for all metadata reading purposes
|
||||
|
|
|
@ -12,10 +12,6 @@ object MetadataExtractorHelper {
|
|||
|
||||
// extensions
|
||||
|
||||
fun Directory.getSafeDescription(tag: Int, save: (value: String) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getDescription(tag))
|
||||
}
|
||||
|
||||
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getString(tag))
|
||||
}
|
||||
|
|
|
@ -8,22 +8,22 @@ import java.io.ByteArrayInputStream
|
|||
|
||||
// `xmlBytes`: bytes representing the XML embedded in a MP4 `uuid` box, according to Spherical Video V1 spec
|
||||
class GSpherical(xmlBytes: ByteArray) {
|
||||
var spherical: Boolean = false
|
||||
var stitched: Boolean = false
|
||||
var stitchingSoftware: String = ""
|
||||
var projectionType: String = ""
|
||||
var stereoMode: String? = null
|
||||
var sourceCount: Int? = null
|
||||
var initialViewHeadingDegrees: Int? = null
|
||||
var initialViewPitchDegrees: Int? = null
|
||||
var initialViewRollDegrees: Int? = null
|
||||
var timestamp: Int? = null
|
||||
var fullPanoWidthPixels: Int? = null
|
||||
var fullPanoHeightPixels: Int? = null
|
||||
var croppedAreaImageWidthPixels: Int? = null
|
||||
var croppedAreaImageHeightPixels: Int? = null
|
||||
var croppedAreaLeftPixels: Int? = null
|
||||
var croppedAreaTopPixels: Int? = null
|
||||
private var spherical: Boolean = false
|
||||
private var stitched: Boolean = false
|
||||
private var stitchingSoftware: String = ""
|
||||
private var projectionType: String = ""
|
||||
private var stereoMode: String? = null
|
||||
private var sourceCount: Int? = null
|
||||
private var initialViewHeadingDegrees: Int? = null
|
||||
private var initialViewPitchDegrees: Int? = null
|
||||
private var initialViewRollDegrees: Int? = null
|
||||
private var timestamp: Int? = null
|
||||
private var fullPanoWidthPixels: Int? = null
|
||||
private var fullPanoHeightPixels: Int? = null
|
||||
private var croppedAreaImageWidthPixels: Int? = null
|
||||
private var croppedAreaImageHeightPixels: Int? = null
|
||||
private var croppedAreaLeftPixels: Int? = null
|
||||
private var croppedAreaTopPixels: Int? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
|
|
|
@ -40,7 +40,7 @@ object TiffTags {
|
|||
// Matteing
|
||||
// Tag = 32995 (80E3.H)
|
||||
// obsoleted by the 6.0 ExtraSamples (338)
|
||||
val TAG_MATTEING = 0x80e3
|
||||
const val TAG_MATTEING = 0x80e3
|
||||
|
||||
/*
|
||||
GeoTIFF
|
||||
|
@ -80,7 +80,7 @@ object TiffTags {
|
|||
// Tag = 34737 (87B1.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
const val TAG_GEO_ASCII_PARAMS = 0x87b1
|
||||
|
||||
/*
|
||||
Photoshop
|
||||
|
@ -91,7 +91,7 @@ object TiffTags {
|
|||
// ImageSourceData
|
||||
// Tag = 37724 (935C.H)
|
||||
// Type = UNDEFINED
|
||||
val TAG_IMAGE_SOURCE_DATA = 0x935c
|
||||
const val TAG_IMAGE_SOURCE_DATA = 0x935c
|
||||
|
||||
/*
|
||||
DNG
|
||||
|
@ -102,13 +102,13 @@ object TiffTags {
|
|||
// Tag = 50735 (C62F.H)
|
||||
// Type = ASCII
|
||||
// Count = variable
|
||||
val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
|
||||
const val TAG_CAMERA_SERIAL_NUMBER = 0xc62f
|
||||
|
||||
// OriginalRawFileName (optional)
|
||||
// Tag = 50827 (C68B.H)
|
||||
// Type = ASCII or BYTE
|
||||
// Count = variable
|
||||
val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
|
||||
const val TAG_ORIGINAL_RAW_FILE_NAME = 0xc68b
|
||||
|
||||
private val tagNameMap = hashMapOf(
|
||||
TAG_X_POSITION to "X Position",
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
|
||||
class AvesEntry(map: FieldMap) {
|
||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||
|
|
|
@ -6,7 +6,7 @@ import android.provider.MediaStore
|
|||
import deckers.thibault.aves.model.SourceEntry
|
||||
|
||||
internal class ContentImageProvider : ImageProvider() {
|
||||
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
if (mimeType == null) {
|
||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||
return
|
||||
|
|
|
@ -6,7 +6,7 @@ import deckers.thibault.aves.model.SourceEntry
|
|||
import java.io.File
|
||||
|
||||
internal class FileImageProvider : ImageProvider() {
|
||||
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
if (mimeType == null) {
|
||||
callback.onFailure(Exception("MIME type is null for uri=$uri"))
|
||||
return
|
||||
|
|
|
@ -35,7 +35,7 @@ import kotlin.coroutines.resumeWithException
|
|||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
abstract class ImageProvider {
|
||||
open suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
open fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
callback.onFailure(UnsupportedOperationException())
|
||||
}
|
||||
|
||||
|
|
|
@ -20,13 +20,12 @@ import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
|
|||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class MediaStoreImageProvider : ImageProvider() {
|
||||
suspend fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
|
||||
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
|
||||
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
|
||||
val knownDate = knownEntries[contentId]
|
||||
return knownDate == null || knownDate < dateModifiedSecs
|
||||
|
@ -35,7 +34,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
|
||||
}
|
||||
|
||||
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||
val id = uri.tryParseId()
|
||||
val onSuccess = fun(entry: FieldMap) {
|
||||
entry["uri"] = uri.toString()
|
||||
|
@ -45,17 +44,17 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
if (id != null) {
|
||||
if (mimeType == null || isImage(mimeType)) {
|
||||
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)) return
|
||||
}
|
||||
if (mimeType == null || isVideo(mimeType)) {
|
||||
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)) return
|
||||
}
|
||||
}
|
||||
// the uri can be a file media URI (e.g. "content://0@media/external/file/30050")
|
||||
// without an equivalent image/video if it is shared from a file browser
|
||||
// but the file is not publicly visible
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = mimeType) > 0) return
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = mimeType)) return
|
||||
|
||||
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
|
||||
}
|
||||
|
@ -109,15 +108,15 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
return obsoleteIds
|
||||
}
|
||||
|
||||
private suspend fun fetchFrom(
|
||||
private fun fetchFrom(
|
||||
context: Context,
|
||||
isValidEntry: NewEntryChecker,
|
||||
handleNewEntry: NewEntryHandler,
|
||||
contentUri: Uri,
|
||||
projection: Array<String>,
|
||||
fileMimeType: String? = null,
|
||||
): Int {
|
||||
var newEntryCount = 0
|
||||
): Boolean {
|
||||
var found = false
|
||||
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
|
||||
try {
|
||||
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
|
||||
|
@ -191,11 +190,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
handleNewEntry(entryMap)
|
||||
// TODO TLAD is this necessary?
|
||||
if (newEntryCount % 30 == 0) {
|
||||
delay(10)
|
||||
}
|
||||
newEntryCount++
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -204,7 +199,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "failed to get entries", e)
|
||||
}
|
||||
return newEntryCount
|
||||
return found
|
||||
}
|
||||
|
||||
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.os.Build
|
||||
|
||||
// compatibility extension for `removeIf` for API < N
|
||||
fun <E> MutableList<E>.compatRemoveIf(filter: (t: E) -> Boolean): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
this.removeIf(filter)
|
||||
} else {
|
||||
var removed = false
|
||||
val each = this.iterator()
|
||||
while (each.hasNext()) {
|
||||
if (filter(each.next())) {
|
||||
each.remove()
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ object MimeTypes {
|
|||
|
||||
// generic raster
|
||||
private const val BMP = "image/bmp"
|
||||
private const val DJVU = "image/vnd.djvu"
|
||||
const val GIF = "image/gif"
|
||||
const val HEIC = "image/heic"
|
||||
private const val HEIF = "image/heif"
|
||||
|
@ -35,6 +36,7 @@ object MimeTypes {
|
|||
private const val VIDEO = "video"
|
||||
|
||||
private const val MP2T = "video/mp2t"
|
||||
private const val MP2TS = "video/mp2ts"
|
||||
private const val WEBM = "video/webm"
|
||||
|
||||
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
|
||||
|
@ -68,7 +70,7 @@ object MimeTypes {
|
|||
|
||||
// as of `metadata-extractor` v2.14.0
|
||||
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
WBMP, MP2T, WEBM -> false
|
||||
DJVU, WBMP, MP2T, MP2TS, WEBM -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,15 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
object PermissionManager {
|
||||
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
|
||||
|
@ -20,6 +23,7 @@ object PermissionManager {
|
|||
// permission request code to pending runnable
|
||||
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
|
||||
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
|
||||
|
||||
|
@ -63,12 +67,23 @@ object PermissionManager {
|
|||
// inaccessible dirs
|
||||
val segments = PathSegments(context, dirPath)
|
||||
segments.volumePath?.let { volumePath ->
|
||||
val dirSet = dirsPerVolume.getOrDefault(volumePath, HashSet())
|
||||
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.apply {
|
||||
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
|
||||
primaryDir?.let { dirSet.add(it) }
|
||||
val relativeDir = segments.relativeDir
|
||||
if (relativeDir != null) {
|
||||
val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
|
||||
val primaryDir = dirSegments.firstOrNull()
|
||||
if (primaryDir == Environment.DIRECTORY_DOWNLOADS && dirSegments.size > 1) {
|
||||
// request secondary directory (if any) for restricted primary directory
|
||||
dirSet.add(dirSegments.take(2).joinToString(File.separator))
|
||||
} else {
|
||||
primaryDir?.let { dirSet.add(it) }
|
||||
}
|
||||
} else {
|
||||
// the requested path is the volume root itself
|
||||
// which cannot be granted, due to Android R restrictions
|
||||
dirSet.add("")
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android Q
|
||||
|
@ -80,28 +95,52 @@ object PermissionManager {
|
|||
}
|
||||
|
||||
// format for easier handling on Flutter
|
||||
val inaccessibleDirs = ArrayList<Map<String, String>>()
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for ((volumePath, relativeDirs) in dirsPerVolume) {
|
||||
var volumeDescription: String? = null
|
||||
try {
|
||||
volumeDescription = sm.getStorageVolume(File(volumePath))?.getDescription(context)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
return ArrayList<Map<String, String>>().apply {
|
||||
addAll(dirsPerVolume.flatMap { (volumePath, relativeDirs) ->
|
||||
relativeDirs.map { relativeDir ->
|
||||
hashMapOf(
|
||||
"volumePath" to volumePath,
|
||||
"relativeDir" to relativeDir,
|
||||
)
|
||||
}
|
||||
for (relativeDir in relativeDirs) {
|
||||
val dirMap = HashMap<String, String>()
|
||||
dirMap["volumePath"] = volumePath
|
||||
dirMap["volumeDescription"] = volumeDescription ?: ""
|
||||
dirMap["relativeDir"] = relativeDir
|
||||
inaccessibleDirs.add(dirMap)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return inaccessibleDirs
|
||||
}
|
||||
|
||||
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
|
||||
val dirs = ArrayList<Map<String, String>>()
|
||||
val sdkInt = Build.VERSION.SDK_INT
|
||||
|
||||
if (sdkInt >= Build.VERSION_CODES.R) {
|
||||
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
|
||||
val volumePaths = StorageUtils.getVolumePaths(context)
|
||||
dirs.addAll(volumePaths.map {
|
||||
hashMapOf(
|
||||
"volumePath" to it,
|
||||
"relativeDir" to "",
|
||||
)
|
||||
})
|
||||
dirs.addAll(volumePaths.map {
|
||||
hashMapOf(
|
||||
"volumePath" to it,
|
||||
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
|
||||
)
|
||||
})
|
||||
} else if (sdkInt == Build.VERSION_CODES.KITKAT || sdkInt == Build.VERSION_CODES.KITKAT_WATCH) {
|
||||
// no SD card volume access on KitKat
|
||||
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)
|
||||
val nonPrimaryVolumes = StorageUtils.getVolumePaths(context).filter { it != primaryVolume }
|
||||
dirs.addAll(nonPrimaryVolumes.map {
|
||||
hashMapOf(
|
||||
"volumePath" to it,
|
||||
"relativeDir" to "",
|
||||
)
|
||||
})
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
|
|
@ -12,6 +12,7 @@ import android.provider.MediaStore
|
|||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import java.io.File
|
||||
|
@ -148,7 +149,7 @@ object StorageUtils {
|
|||
return paths.map { ensureTrailingSeparator(it) }.toTypedArray()
|
||||
}
|
||||
|
||||
// return physicalPaths based on phone model
|
||||
// returns physicalPaths based on phone model
|
||||
@SuppressLint("SdCardPath")
|
||||
private val physicalPaths = arrayOf(
|
||||
"/storage/sdcard0",
|
||||
|
@ -177,41 +178,68 @@ object StorageUtils {
|
|||
* Volume tree URIs
|
||||
*/
|
||||
|
||||
// e.g.
|
||||
// /storage/emulated/0/ -> primary
|
||||
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
|
||||
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
val volume = sm.getStorageVolume(File(anyPath))
|
||||
if (volume != null) {
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
val uuid = volume.uuid
|
||||
if (uuid != null) {
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
sm.getStorageVolume(File(anyPath))?.let { volume ->
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
volume.uuid?.let { uuid ->
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for <N
|
||||
getVolumePath(context, anyPath)?.let { volumePath ->
|
||||
if (volumePath == getPrimaryVolumePath(context)) {
|
||||
return "primary"
|
||||
}
|
||||
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
|
||||
return uuid.toUpperCase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(LOG_TAG, "failed to find volume UUID for anyPath=$anyPath")
|
||||
return null
|
||||
}
|
||||
|
||||
// e.g.
|
||||
// primary -> /storage/emulated/0/
|
||||
// 10F9-3F13 -> /storage/10F9-3F13/
|
||||
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
|
||||
if (uuid == "primary") {
|
||||
return getPrimaryVolumePath(context)
|
||||
}
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
if (volume != null && uuid.equals(volume.uuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for <N
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
val volumeUuid = volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }
|
||||
if (uuid.equals(volumeUuid, ignoreCase = true)) {
|
||||
return volumePath
|
||||
}
|
||||
}
|
||||
|
||||
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
|
||||
return null
|
||||
}
|
||||
|
@ -219,6 +247,7 @@ object StorageUtils {
|
|||
// e.g.
|
||||
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
|
||||
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
|
||||
val uuid = getVolumeUuidForTreeUri(context, dirPath)
|
||||
if (uuid != null) {
|
||||
|
@ -260,7 +289,7 @@ object StorageUtils {
|
|||
|
||||
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
||||
try {
|
||||
if (requireAccessPermission(context, anyPath)) {
|
||||
if (requireAccessPermission(context, anyPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
|
||||
// cleanest API to get it
|
||||
|
@ -284,7 +313,7 @@ object StorageUtils {
|
|||
// returns null if directory does not exist and could not be created
|
||||
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
||||
val cleanDirPath = ensureTrailingSeparator(dirPath)
|
||||
return if (requireAccessPermission(context, cleanDirPath)) {
|
||||
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
|
||||
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.21'
|
||||
ext.kotlin_version = '1.4.30'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808
|
||||
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/commit/8dd0de7f580972079f610a56a689b0a9c414f81e
|
||||
classpath 'com.android.tools.build:gradle:3.6.4'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.0'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// TODO TLAD remove jcenter (migrating to mavenCentral) when this is fixed: https://youtrack.jetbrains.com/issue/IDEA-261387
|
||||
jcenter()
|
||||
}
|
||||
// gradle.projectsEvaluated {
|
||||
|
|
1
assets/countries-50m.json
Normal file
1
assets/countries-50m.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -4,7 +4,7 @@ Aves is an open-source gallery and metadata explorer app allowing you to access
|
|||
You must use the app for legal, authorized and acceptable purposes.
|
||||
|
||||
# Disclaimer
|
||||
This app is released "as-is", without any warranty, responsibility or liability. Use of the app is at your own risk.
|
||||
This app is released “as-is”, without any warranty, responsibility or liability. Use of the app is at your own risk.
|
||||
|
||||
# Privacy policy
|
||||
Aves does not collect any personal data in its standard use. We never have access to your photos and videos. This also means that we cannot get them back for you if you delete them without backing them up.
|
||||
|
|
Binary file not shown.
Binary file not shown.
103
lib/geo/countries.dart
Normal file
103
lib/geo/countries.dart
Normal file
|
@ -0,0 +1,103 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/geo/topojson.dart';
|
||||
import 'package:country_code/country_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
||||
final CountryTopology countryTopology = CountryTopology._private();
|
||||
|
||||
class CountryTopology {
|
||||
static const topoJsonAsset = 'assets/countries-50m.json';
|
||||
|
||||
CountryTopology._private();
|
||||
|
||||
Topology _topology;
|
||||
|
||||
Future<Topology> getTopology() => _topology != null ? SynchronousFuture(_topology) : rootBundle.loadString(topoJsonAsset).then(TopoJson().parse);
|
||||
|
||||
// returns the country containing given coordinates
|
||||
Future<CountryCode> countryCode(LatLng position) async {
|
||||
return _countryOfNumeric(await numericCode(position));
|
||||
}
|
||||
|
||||
// returns the ISO 3166-1 numeric code of the country containing given coordinates
|
||||
Future<int> numericCode(LatLng position) async {
|
||||
final topology = await getTopology();
|
||||
if (topology == null) return null;
|
||||
|
||||
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
|
||||
return _getNumeric(topology, countries, position);
|
||||
}
|
||||
|
||||
// returns a map of the given positions by country
|
||||
Future<Map<CountryCode, Set<LatLng>>> countryCodeMap(Set<LatLng> positions) async {
|
||||
final numericMap = await numericCodeMap(positions);
|
||||
numericMap.remove(null);
|
||||
final codeMap = numericMap.map((key, value) {
|
||||
final code = _countryOfNumeric(key);
|
||||
return code == null ? null : MapEntry(code, value);
|
||||
});
|
||||
codeMap.remove(null);
|
||||
return codeMap;
|
||||
}
|
||||
|
||||
// returns a map of the given positions by the ISO 3166-1 numeric code of the country containing them
|
||||
Future<Map<int, Set<LatLng>>> numericCodeMap(Set<LatLng> positions) async {
|
||||
final topology = await getTopology();
|
||||
if (topology == null) return null;
|
||||
|
||||
return compute(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
|
||||
}
|
||||
|
||||
static Future<Map<int, Set<LatLng>>> _isoNumericCodeMap(_IsoNumericCodeMapData data) async {
|
||||
try {
|
||||
final topology = data.topology;
|
||||
final countries = (topology.objects['countries'] as GeometryCollection).geometries;
|
||||
final byCode = <int, Set<LatLng>>{};
|
||||
for (final position in data.positions) {
|
||||
final code = _getNumeric(topology, countries, position);
|
||||
byCode[code] = (byCode[code] ?? {})..add(position);
|
||||
}
|
||||
return byCode;
|
||||
} catch (error, stack) {
|
||||
// an unhandled error in a spawn isolate would make the app crash
|
||||
debugPrint('failed to get country codes with error=$error\n$stack');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int _getNumeric(Topology topology, List<Geometry> mruCountries, LatLng position) {
|
||||
final point = [position.longitude, position.latitude];
|
||||
final hit = mruCountries.firstWhere((country) => country.containsPoint(topology, point), orElse: () => null);
|
||||
if (hit == null) return null;
|
||||
|
||||
// promote hit countries, assuming given positions are likely to come from the same countries
|
||||
if (mruCountries.first != hit) {
|
||||
mruCountries.remove(hit);
|
||||
mruCountries.insert(0, hit);
|
||||
}
|
||||
|
||||
final idString = (hit.id as String);
|
||||
final code = idString == null ? null : int.tryParse(idString);
|
||||
return code;
|
||||
}
|
||||
|
||||
static CountryCode _countryOfNumeric(int numeric) {
|
||||
if (numeric == null) return null;
|
||||
try {
|
||||
return CountryCode.ofNumeric(numeric);
|
||||
} catch (error) {
|
||||
debugPrint('failed to find country for numeric=$numeric with error=$error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _IsoNumericCodeMapData {
|
||||
Topology topology;
|
||||
Set<LatLng> positions;
|
||||
|
||||
_IsoNumericCodeMapData(this.topology, this.positions);
|
||||
}
|
|
@ -21,7 +21,7 @@ String _decimal2sexagesimal(final double degDecimal) {
|
|||
return '$deg° $min′ ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}″';
|
||||
}
|
||||
|
||||
// return coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
// returns coordinates formatted as DMS, e.g. ['41° 24′ 12.2″ N', '2° 10′ 26.5″ E']
|
||||
List<String> toDMS(LatLng latLng) {
|
||||
if (latLng == null) return [];
|
||||
final lat = latLng.latitude;
|
245
lib/geo/topojson.dart
Normal file
245
lib/geo/topojson.dart
Normal file
|
@ -0,0 +1,245 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// cf https://github.com/topojson/topojson-specification
|
||||
class TopoJson {
|
||||
Future<Topology> parse(String data) async {
|
||||
return compute(_isoParse, data);
|
||||
}
|
||||
|
||||
static Topology _isoParse(String jsonData) {
|
||||
try {
|
||||
final data = json.decode(jsonData) as Map<String, dynamic>;
|
||||
return Topology.parse(data);
|
||||
} catch (error, stack) {
|
||||
// an unhandled error in a spawn isolate would make the app crash
|
||||
debugPrint('failed to parse TopoJSON with error=$error\n$stack');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
enum TopoJsonObjectType { topology, point, multipoint, linestring, multilinestring, polygon, multipolygon, geometrycollection }
|
||||
|
||||
TopoJsonObjectType _parseTopoJsonObjectType(String data) {
|
||||
switch (data) {
|
||||
case 'Topology':
|
||||
return TopoJsonObjectType.topology;
|
||||
case 'Point':
|
||||
return TopoJsonObjectType.point;
|
||||
case 'MultiPoint':
|
||||
return TopoJsonObjectType.multipoint;
|
||||
case 'LineString':
|
||||
return TopoJsonObjectType.linestring;
|
||||
case 'MultiLineString':
|
||||
return TopoJsonObjectType.multilinestring;
|
||||
case 'Polygon':
|
||||
return TopoJsonObjectType.polygon;
|
||||
case 'MultiPolygon':
|
||||
return TopoJsonObjectType.multipolygon;
|
||||
case 'GeometryCollection':
|
||||
return TopoJsonObjectType.geometrycollection;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class TopologyJsonObject {
|
||||
final List<num> bbox;
|
||||
|
||||
TopologyJsonObject.parse(Map<String, dynamic> data) : bbox = data.containsKey('bbox') ? (data['bbox'] as List).cast<num>().toList() : null;
|
||||
}
|
||||
|
||||
class Topology extends TopologyJsonObject {
|
||||
final Map<String, Geometry> objects;
|
||||
final List<List<List<num>>> arcs;
|
||||
final Transform transform;
|
||||
|
||||
Topology.parse(Map<String, dynamic> data)
|
||||
: objects = (data['objects'] as Map).cast<String, dynamic>().map<String, Geometry>((name, geometry) => MapEntry(name, Geometry.build(geometry))),
|
||||
arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<List>().map((position) => position.cast<num>()).toList()).toList(),
|
||||
transform = data.containsKey('transform') ? Transform.parse((data['transform'] as Map).cast<String, dynamic>()) : null,
|
||||
super.parse(data);
|
||||
|
||||
List<List<num>> _arcAt(int index) {
|
||||
var arc = arcs[index < 0 ? ~index : index];
|
||||
|
||||
if (transform != null) {
|
||||
var x = 0, y = 0;
|
||||
arc = arc.map((quantized) {
|
||||
final absolute = List.of(quantized);
|
||||
absolute[0] = (x += quantized[0]) * transform.scale[0] + transform.translate[0];
|
||||
absolute[1] = (y += quantized[1]) * transform.scale[1] + transform.translate[1];
|
||||
return absolute;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return index < 0 ? arc.reversed.toList() : arc;
|
||||
}
|
||||
|
||||
List<List<num>> _toLine(List<List<List<num>>> arcs) {
|
||||
return arcs.fold(<List<num>>[], (prev, arc) => [...prev, ...prev.isEmpty ? arc : arc.skip(1)]);
|
||||
}
|
||||
|
||||
List<List<num>> _decodeRingArcs(List<int> ringArcs) {
|
||||
return _toLine(ringArcs.map(_arcAt).toList());
|
||||
}
|
||||
|
||||
List<List<List<num>>> _decodePolygonArcs(List<List<int>> polyArcs) {
|
||||
return polyArcs.map(_decodeRingArcs).toList();
|
||||
}
|
||||
|
||||
List<List<List<List<num>>>> _decodeMultiPolygonArcs(List<List<List<int>>> multiPolyArcs) {
|
||||
return multiPolyArcs.map(_decodePolygonArcs).toList();
|
||||
}
|
||||
|
||||
// cf https://en.wikipedia.org/wiki/Even%E2%80%93odd_rule
|
||||
bool _pointInRing(List<num> point, List<List<num>> poly) {
|
||||
final x = point[0];
|
||||
final y = point[1];
|
||||
final length = poly.length;
|
||||
var j = length - 1;
|
||||
var c = false;
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (((poly[i][1] > y) != (poly[j][1] > y)) && (x < poly[i][0] + (poly[j][0] - poly[i][0]) * (y - poly[i][1]) / (poly[j][1] - poly[i][1]))) {
|
||||
c = !c;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
bool _pointInRings(List<num> point, List<List<List<num>>> rings) {
|
||||
return rings.any((ring) => _pointInRing(point, ring));
|
||||
}
|
||||
}
|
||||
|
||||
class Transform {
|
||||
final List<num> scale;
|
||||
final List<num> translate;
|
||||
|
||||
Transform.parse(Map<String, dynamic> data)
|
||||
: scale = (data['scale'] as List).cast<num>(),
|
||||
translate = (data['translate'] as List).cast<num>();
|
||||
}
|
||||
|
||||
abstract class Geometry extends TopologyJsonObject {
|
||||
final dynamic id;
|
||||
final Map<String, dynamic> properties;
|
||||
|
||||
Geometry.parse(Map<String, dynamic> data)
|
||||
: id = data.containsKey('id') ? data['id'] : null,
|
||||
properties = data.containsKey('properties') ? data['properties'] as Map<String, dynamic> : null,
|
||||
super.parse(data);
|
||||
|
||||
static Geometry build(Map<String, dynamic> data) {
|
||||
final type = _parseTopoJsonObjectType(data['type'] as String);
|
||||
switch (type) {
|
||||
case TopoJsonObjectType.topology:
|
||||
return null;
|
||||
case TopoJsonObjectType.point:
|
||||
return Point.parse(data);
|
||||
case TopoJsonObjectType.multipoint:
|
||||
return MultiPoint.parse(data);
|
||||
case TopoJsonObjectType.linestring:
|
||||
return LineString.parse(data);
|
||||
case TopoJsonObjectType.multilinestring:
|
||||
return MultiLineString.parse(data);
|
||||
case TopoJsonObjectType.polygon:
|
||||
return Polygon.parse(data);
|
||||
case TopoJsonObjectType.multipolygon:
|
||||
return MultiPolygon.parse(data);
|
||||
case TopoJsonObjectType.geometrycollection:
|
||||
return GeometryCollection.parse(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool containsPoint(Topology topology, List<num> point) => false;
|
||||
}
|
||||
|
||||
class Point extends Geometry {
|
||||
final List<num> coordinates;
|
||||
|
||||
Point.parse(Map<String, dynamic> data)
|
||||
: coordinates = (data['coordinates'] as List).cast<num>(),
|
||||
super.parse(data);
|
||||
}
|
||||
|
||||
class MultiPoint extends Geometry {
|
||||
final List<List<num>> coordinates;
|
||||
|
||||
MultiPoint.parse(Map<String, dynamic> data)
|
||||
: coordinates = (data['coordinates'] as List).cast<List>().map((position) => position.cast<num>()).toList(),
|
||||
super.parse(data);
|
||||
}
|
||||
|
||||
class LineString extends Geometry {
|
||||
final List<int> arcs;
|
||||
|
||||
LineString.parse(Map<String, dynamic> data)
|
||||
: arcs = (data['arcs'] as List).cast<int>(),
|
||||
super.parse(data);
|
||||
}
|
||||
|
||||
class MultiLineString extends Geometry {
|
||||
final List<List<int>> arcs;
|
||||
|
||||
MultiLineString.parse(Map<String, dynamic> data)
|
||||
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
|
||||
super.parse(data);
|
||||
}
|
||||
|
||||
class Polygon extends Geometry {
|
||||
final List<List<int>> arcs;
|
||||
|
||||
Polygon.parse(Map<String, dynamic> data)
|
||||
: arcs = (data['arcs'] as List).cast<List>().map((arc) => arc.cast<int>()).toList(),
|
||||
super.parse(data);
|
||||
|
||||
List<List<List<num>>> _rings;
|
||||
|
||||
List<List<List<num>>> rings(Topology topology) {
|
||||
_rings ??= topology._decodePolygonArcs(arcs);
|
||||
return _rings;
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsPoint(Topology topology, List<num> point) {
|
||||
return topology._pointInRings(point, rings(topology));
|
||||
}
|
||||
}
|
||||
|
||||
class MultiPolygon extends Geometry {
|
||||
final List<List<List<int>>> arcs;
|
||||
|
||||
MultiPolygon.parse(Map<String, dynamic> data)
|
||||
: arcs = (data['arcs'] as List).cast<List>().map((polygon) => polygon.cast<List>().map((arc) => arc.cast<int>()).toList()).toList(),
|
||||
super.parse(data);
|
||||
|
||||
List<List<List<List<num>>>> _polygons;
|
||||
|
||||
List<List<List<List<num>>>> polygons(Topology topology) {
|
||||
_polygons ??= topology._decodeMultiPolygonArcs(arcs);
|
||||
return _polygons;
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsPoint(Topology topology, List<num> point) {
|
||||
return polygons(topology).any((polygon) => topology._pointInRings(point, polygon));
|
||||
}
|
||||
}
|
||||
|
||||
class GeometryCollection extends Geometry {
|
||||
final List<Geometry> geometries;
|
||||
|
||||
GeometryCollection.parse(Map<String, dynamic> data)
|
||||
: geometries = (data['geometries'] as List).cast<Map<String, dynamic>>().map(Geometry.build).toList(),
|
||||
super.parse(data);
|
||||
|
||||
@override
|
||||
bool containsPoint(Topology topology, List<num> point) {
|
||||
return geometries.any((geometry) => geometry.containsPoint(topology, point));
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
|
@ -74,8 +75,8 @@ class _AvesAppState extends State<AvesApp> {
|
|||
textTheme: TextTheme(
|
||||
headline6: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Concourse Caps',
|
||||
fontWeight: FontWeight.normal,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -114,24 +115,26 @@ class _AvesAppState extends State<AvesApp> {
|
|||
value: settings,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||
);
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
title: 'Aves',
|
||||
darkTheme: darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
);
|
||||
},
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
||||
? getFirstPage()
|
||||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||
);
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
title: 'Aves',
|
||||
darkTheme: darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -14,6 +14,9 @@ enum ChipAction {
|
|||
pin,
|
||||
unpin,
|
||||
rename,
|
||||
goToAlbumPage,
|
||||
goToCountryPage,
|
||||
goToTagPage,
|
||||
}
|
||||
|
||||
extension ExtraChipAction on ChipAction {
|
||||
|
@ -21,6 +24,12 @@ extension ExtraChipAction on ChipAction {
|
|||
switch (this) {
|
||||
case ChipAction.delete:
|
||||
return 'Delete';
|
||||
case ChipAction.goToAlbumPage:
|
||||
return 'Show in Albums';
|
||||
case ChipAction.goToCountryPage:
|
||||
return 'Show in Countries';
|
||||
case ChipAction.goToTagPage:
|
||||
return 'Show in Tags';
|
||||
case ChipAction.hide:
|
||||
return 'Hide';
|
||||
case ChipAction.pin:
|
||||
|
@ -37,6 +46,12 @@ extension ExtraChipAction on ChipAction {
|
|||
switch (this) {
|
||||
case ChipAction.delete:
|
||||
return AIcons.delete;
|
||||
case ChipAction.goToAlbumPage:
|
||||
return AIcons.album;
|
||||
case ChipAction.goToCountryPage:
|
||||
return AIcons.location;
|
||||
case ChipAction.goToTagPage:
|
||||
return AIcons.tag;
|
||||
case ChipAction.hide:
|
||||
return AIcons.hide;
|
||||
case ChipAction.pin:
|
||||
|
|
|
@ -42,8 +42,8 @@ class AvesAvailability {
|
|||
return _hasPlayServices;
|
||||
}
|
||||
|
||||
// local geolocation with `geocoder` requires Play Services
|
||||
Future<bool> get canGeolocate => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
||||
// local geocoding with `geocoder` requires Play Services
|
||||
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
|
||||
|
||||
Future<bool> get isNewVersionAvailable async {
|
||||
if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable);
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/geo/countries.dart';
|
||||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/entry_cache.dart';
|
||||
import 'package:aves/model/favourite_repo.dart';
|
||||
import 'package:aves/model/metadata.dart';
|
||||
|
@ -13,6 +15,7 @@ import 'package:aves/utils/change_notifier.dart';
|
|||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:country_code/country_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geocoder/geocoder.dart';
|
||||
|
@ -47,7 +50,7 @@ class AvesEntry {
|
|||
final Future<List<Address>> Function(Coordinates coordinates) _findAddresses = Geocoder.local.findAddressesFromCoordinates;
|
||||
|
||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
|
||||
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.djvu, MimeTypes.psd];
|
||||
|
||||
AvesEntry({
|
||||
this.uri,
|
||||
|
@ -286,6 +289,8 @@ class AvesEntry {
|
|||
static const ratioSeparator = '\u2236';
|
||||
static const resolutionSeparator = ' \u00D7 ';
|
||||
|
||||
bool get isSized => (width ?? 0) > 0 && (height ?? 0) > 0;
|
||||
|
||||
String get resolutionText {
|
||||
final ws = width ?? '?';
|
||||
final hs = height ?? '?';
|
||||
|
@ -366,9 +371,15 @@ class AvesEntry {
|
|||
return _durationText;
|
||||
}
|
||||
|
||||
bool get hasGps => isCatalogued && _catalogMetadata.latitude != null;
|
||||
// returns whether this entry has GPS coordinates
|
||||
// (0, 0) coordinates are considered invalid, as it is likely a default value
|
||||
bool get hasGps => _catalogMetadata != null && _catalogMetadata.latitude != null && _catalogMetadata.longitude != null && (_catalogMetadata.latitude != 0 || _catalogMetadata.longitude != 0);
|
||||
|
||||
bool get isLocated => _addressDetails != null;
|
||||
bool get hasAddress => _addressDetails != null;
|
||||
|
||||
// has a place, or at least the full country name
|
||||
// derived from Google reverse geocoding addresses
|
||||
bool get hasFineAddress => _addressDetails != null && (_addressDetails.place?.isNotEmpty == true || (_addressDetails.countryName?.length ?? 0) > 3);
|
||||
|
||||
LatLng get latLng => hasGps ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
|
||||
|
||||
|
@ -389,7 +400,7 @@ class AvesEntry {
|
|||
String _bestTitle;
|
||||
|
||||
String get bestTitle {
|
||||
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||
_bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata.xmpTitleDescription : sourceTitle;
|
||||
return _bestTitle;
|
||||
}
|
||||
|
||||
|
@ -444,17 +455,36 @@ class AvesEntry {
|
|||
addressChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> locate({bool background = false}) async {
|
||||
if (isLocated) return;
|
||||
Future<void> locate({@required bool background}) async {
|
||||
if (!hasGps) return;
|
||||
await _locateCountry();
|
||||
if (await availability.canLocatePlaces) {
|
||||
await locatePlace(background: background);
|
||||
}
|
||||
}
|
||||
|
||||
await catalog(background: background);
|
||||
final latitude = _catalogMetadata?.latitude;
|
||||
final longitude = _catalogMetadata?.longitude;
|
||||
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return;
|
||||
// quick reverse geocoding to find the country, using an offline asset
|
||||
Future<void> _locateCountry() async {
|
||||
if (!hasGps || hasAddress) return;
|
||||
final countryCode = await countryTopology.countryCode(latLng);
|
||||
setCountry(countryCode);
|
||||
}
|
||||
|
||||
final coordinates = Coordinates(latitude, longitude);
|
||||
void setCountry(CountryCode countryCode) {
|
||||
if (hasFineAddress || countryCode == null) return;
|
||||
addressDetails = AddressDetails(
|
||||
contentId: contentId,
|
||||
countryCode: countryCode.alpha2,
|
||||
countryName: countryCode.alpha3,
|
||||
);
|
||||
}
|
||||
|
||||
// full reverse geocoding, requiring Play Services and some connectivity
|
||||
Future<void> locatePlace({@required bool background}) async {
|
||||
if (!hasGps || hasFineAddress) return;
|
||||
final coordinates = latLng;
|
||||
try {
|
||||
Future<List<Address>> call() => _findAddresses(coordinates);
|
||||
Future<List<Address>> call() => _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
|
||||
final addresses = await (background
|
||||
? servicePolicy.call(
|
||||
call,
|
||||
|
@ -476,38 +506,34 @@ class AvesEntry {
|
|||
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
|
||||
);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> findAddressLine() async {
|
||||
final latitude = _catalogMetadata?.latitude;
|
||||
final longitude = _catalogMetadata?.longitude;
|
||||
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
|
||||
if (!hasGps) return null;
|
||||
|
||||
final coordinates = Coordinates(latitude, longitude);
|
||||
final coordinates = latLng;
|
||||
try {
|
||||
final addresses = await _findAddresses(coordinates);
|
||||
final addresses = await _findAddresses(Coordinates(coordinates.latitude, coordinates.longitude));
|
||||
if (addresses != null && addresses.isNotEmpty) {
|
||||
final address = addresses.first;
|
||||
return address.addressLine;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stack');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get shortAddress {
|
||||
if (!isLocated) return '';
|
||||
|
||||
// `admin area` examples: Seoul, Geneva, null
|
||||
// `locality` examples: Mapo-gu, Geneva, Annecy
|
||||
return {
|
||||
_addressDetails.countryName,
|
||||
_addressDetails.adminArea,
|
||||
_addressDetails.locality,
|
||||
_addressDetails?.countryName,
|
||||
_addressDetails?.adminArea,
|
||||
_addressDetails?.locality,
|
||||
}.where((part) => part != null && part.isNotEmpty).join(', ');
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/query.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -17,6 +18,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
QueryFilter.type,
|
||||
FavouriteFilter.type,
|
||||
MimeFilter.type,
|
||||
TypeFilter.type,
|
||||
AlbumFilter.type,
|
||||
LocationFilter.type,
|
||||
TagFilter.type,
|
||||
|
@ -32,6 +34,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
|||
return FavouriteFilter();
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
case MimeFilter.type:
|
||||
return MimeFilter.fromMap(jsonMap);
|
||||
case QueryFilter.type:
|
||||
|
|
|
@ -19,7 +19,7 @@ class LocationFilter extends CollectionFilter {
|
|||
if (split.length > 1) _countryCode = split[1];
|
||||
|
||||
if (_location.isEmpty) {
|
||||
_test = (entry) => !entry.isLocated;
|
||||
_test = (entry) => !entry.hasGps;
|
||||
} else if (level == LocationLevel.country) {
|
||||
_test = (entry) => entry.addressDetails?.countryCode == _countryCode;
|
||||
} else if (level == LocationLevel.place) {
|
||||
|
@ -55,7 +55,13 @@ class LocationFilter extends CollectionFilter {
|
|||
final flag = countryCodeToFlag(_countryCode);
|
||||
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
|
||||
// not filled with the shadow color as expected, so we remove them
|
||||
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []));
|
||||
if (flag != null) {
|
||||
return Text(
|
||||
flag,
|
||||
style: TextStyle(fontSize: size, shadows: []),
|
||||
textScaleFactor: 1.0,
|
||||
);
|
||||
}
|
||||
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,12 +7,6 @@ import 'package:flutter/widgets.dart';
|
|||
class MimeFilter extends CollectionFilter {
|
||||
static const type = 'mime';
|
||||
|
||||
// fake mime type
|
||||
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
|
||||
static const panorama = 'aves/panorama'; // subset of images
|
||||
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
|
||||
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
|
||||
|
||||
final String mime;
|
||||
EntryFilter _test;
|
||||
String _label;
|
||||
|
@ -20,23 +14,7 @@ class MimeFilter extends CollectionFilter {
|
|||
|
||||
MimeFilter(this.mime) {
|
||||
var lowMime = mime.toLowerCase();
|
||||
if (mime == animated) {
|
||||
_test = (entry) => entry.isAnimated;
|
||||
_label = 'Animated';
|
||||
_icon = AIcons.animated;
|
||||
} else if (mime == panorama) {
|
||||
_test = (entry) => entry.isImage && entry.is360;
|
||||
_label = 'Panorama';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (mime == sphericalVideo) {
|
||||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_label = '360° Video';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (mime == geotiff) {
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_label = 'GeoTIFF';
|
||||
_icon = AIcons.geo;
|
||||
} else if (lowMime.endsWith('/*')) {
|
||||
if (lowMime.endsWith('/*')) {
|
||||
lowMime = lowMime.substring(0, lowMime.length - 2);
|
||||
_test = (entry) => entry.mimeType.startsWith(lowMime);
|
||||
if (lowMime == 'video') {
|
||||
|
|
73
lib/model/filters/type.dart
Normal file
73
lib/model/filters/type.dart
Normal file
|
@ -0,0 +1,73 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TypeFilter extends CollectionFilter {
|
||||
static const type = 'type';
|
||||
|
||||
static const animated = 'animated'; // subset of `image/gif` and `image/webp`
|
||||
static const geotiff = 'geotiff'; // subset of `image/tiff`
|
||||
static const panorama = 'panorama'; // subset of images
|
||||
static const sphericalVideo = 'spherical_video'; // subset of videos
|
||||
|
||||
final String itemType;
|
||||
EntryFilter _test;
|
||||
String _label;
|
||||
IconData _icon;
|
||||
|
||||
TypeFilter(this.itemType) {
|
||||
if (itemType == animated) {
|
||||
_test = (entry) => entry.isAnimated;
|
||||
_label = 'Animated';
|
||||
_icon = AIcons.animated;
|
||||
} else if (itemType == panorama) {
|
||||
_test = (entry) => entry.isImage && entry.is360;
|
||||
_label = 'Panorama';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (itemType == sphericalVideo) {
|
||||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_label = '360° Video';
|
||||
_icon = AIcons.threesixty;
|
||||
} else if (itemType == geotiff) {
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_label = 'GeoTIFF';
|
||||
_icon = AIcons.geo;
|
||||
}
|
||||
}
|
||||
|
||||
TypeFilter.fromMap(Map<String, dynamic> json)
|
||||
: this(
|
||||
json['itemType'],
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toMap() => {
|
||||
'type': type,
|
||||
'itemType': itemType,
|
||||
};
|
||||
|
||||
@override
|
||||
EntryFilter get test => _test;
|
||||
|
||||
@override
|
||||
String get label => _label;
|
||||
|
||||
@override
|
||||
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
|
||||
|
||||
@override
|
||||
String get typeKey => type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is TypeFilter && other.itemType == itemType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, itemType);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{itemType=$itemType}';
|
||||
}
|
|
@ -1,24 +1,24 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class HighlightInfo extends ChangeNotifier {
|
||||
final Queue<Object> _items = Queue();
|
||||
Object _item;
|
||||
|
||||
void add(Object item) {
|
||||
if (_items.contains(item)) return;
|
||||
|
||||
_items.addFirst(item);
|
||||
while (_items.length > 5) {
|
||||
_items.removeLast();
|
||||
}
|
||||
void set(Object item) {
|
||||
if (_item == item) return;
|
||||
_item = item;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(Object item) {
|
||||
_items.removeWhere((element) => element == item);
|
||||
Object clear() {
|
||||
if (_item == null) return null;
|
||||
final item = _item;
|
||||
_item = null;
|
||||
notifyListeners();
|
||||
return item;
|
||||
}
|
||||
|
||||
bool contains(Object item) => _items.contains(item);
|
||||
bool contains(Object item) => _item == item;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
|
||||
}
|
||||
|
|
|
@ -156,13 +156,14 @@ class OverlayMetadata {
|
|||
String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AddressDetails {
|
||||
final int contentId;
|
||||
final String countryCode, countryName, adminArea, locality;
|
||||
|
||||
String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
|
||||
|
||||
AddressDetails({
|
||||
const AddressDetails({
|
||||
this.contentId,
|
||||
this.countryCode,
|
||||
this.countryName,
|
||||
|
|
|
@ -195,8 +195,8 @@ class MetadataDb {
|
|||
metadataEntries.where((metadata) => metadata != null).forEach((metadata) => _batchInsertMetadata(batch, metadata));
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
} catch (exception, stack) {
|
||||
debugPrint('$runtimeType failed to save metadata with exception=$exception\n$stack');
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:aves/geo/format.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
||||
enum CoordinateFormat { dms, decimal }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:screen/screen.dart';
|
||||
import 'package:aves/services/window_service.dart';
|
||||
|
||||
enum KeepScreenOn { never, viewerOnly, always }
|
||||
|
||||
|
@ -17,6 +17,6 @@ extension ExtraKeepScreenOn on KeepScreenOn {
|
|||
}
|
||||
|
||||
void apply() {
|
||||
Screen.keepOn(this == KeepScreenOn.always);
|
||||
WindowService.keepScreenOn(this == KeepScreenOn.always);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
|
||||
|
||||
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, true);
|
||||
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, false);
|
||||
|
||||
set isCrashlyticsEnabled(bool newValue) {
|
||||
setAndNotify(isCrashlyticsEnabledKey, newValue);
|
||||
|
|
|
@ -23,34 +23,36 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getUniqueAlbumName(String album) {
|
||||
final otherAlbums = _directories.where((item) => item != album);
|
||||
final parts = album.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
do {
|
||||
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
|
||||
} while (otherAlbums.any((item) => item.endsWith(testName)));
|
||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
||||
|
||||
final volume = androidFileUtils.getStorageVolume(album);
|
||||
if (volume == null) {
|
||||
String getUniqueAlbumName(String dirPath) {
|
||||
String unique(String dirPath, [bool Function(String) test]) {
|
||||
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
|
||||
final parts = dirPath.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
do {
|
||||
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
|
||||
} while (otherAlbums.any((item) => item.endsWith(testName)));
|
||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
final volumeRootLength = volume.path.length;
|
||||
if (album.length < volumeRootLength) {
|
||||
// `album` is at the root, without trailing '/'
|
||||
return uniqueName;
|
||||
}
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
final albumRelativePath = album.substring(volumeRootLength);
|
||||
if (uniqueName.length < albumRelativePath.length) {
|
||||
return uniqueName;
|
||||
} else if (volume.isPrimary) {
|
||||
return albumRelativePath;
|
||||
final uniqueNameInDevice = unique(dirPath);
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) return uniqueNameInDevice;
|
||||
|
||||
if (uniqueNameInDevice.length < relativeDir.length) {
|
||||
return uniqueNameInDevice;
|
||||
} else {
|
||||
return '$albumRelativePath (${volume.description})';
|
||||
final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
if (volume.isPrimary) {
|
||||
return uniqueNameInVolume;
|
||||
} else {
|
||||
return '$uniqueNameInVolume (${volume.description})';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,6 +121,7 @@ mixin AlbumMixin on SourceBase {
|
|||
directories.forEach(_filterEntryCountMap.remove);
|
||||
directories.forEach(_filterRecentEntryMap.remove);
|
||||
}
|
||||
eventBus.fire(AlbumSummaryInvalidatedEvent(directories));
|
||||
}
|
||||
|
||||
int albumEntryCount(AlbumFilter filter) {
|
||||
|
@ -131,3 +134,9 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
class AlbumsChangedEvent {}
|
||||
|
||||
class AlbumSummaryInvalidatedEvent {
|
||||
final Set<String> directories;
|
||||
|
||||
const AlbumSummaryInvalidatedEvent(this.directories);
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin {
|
|||
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
|
||||
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(source.eventBus.on<AddressMetadataChangedEvent>().listen((e) {
|
||||
if (this.filters.any((filter) => filter is LocationFilter)) {
|
||||
|
|
|
@ -23,6 +23,8 @@ mixin SourceBase {
|
|||
|
||||
List<AvesEntry> get sortedEntriesByDate;
|
||||
|
||||
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
|
||||
|
||||
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast();
|
||||
|
||||
Stream<ProgressEvent> get progressStream => _progressStreamController.stream;
|
||||
|
@ -58,8 +60,6 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return _sortedEntriesByDate;
|
||||
}
|
||||
|
||||
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
|
||||
|
||||
List<DateMetadata> _savedDates;
|
||||
|
||||
Future<void> loadDates() async {
|
||||
|
@ -248,6 +248,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filter, visible));
|
||||
|
||||
if (visible) {
|
||||
refreshMetadata(visibleEntries.where(filter.test).toSet());
|
||||
}
|
||||
|
@ -274,6 +276,13 @@ class EntryMovedEvent {
|
|||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
||||
class FilterVisibilityChangedEvent {
|
||||
final CollectionFilter filter;
|
||||
final bool visible;
|
||||
|
||||
const FilterVisibilityChangedEvent(this.filter, this.visible);
|
||||
}
|
||||
|
||||
class ProgressEvent {
|
||||
final int done, total;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/geo/countries.dart';
|
||||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
|
@ -28,10 +29,45 @@ mixin LocationMixin on SourceBase {
|
|||
}
|
||||
|
||||
Future<void> locateEntries() async {
|
||||
if (!(await availability.canGeolocate)) return;
|
||||
await _locateCountries();
|
||||
await _locatePlaces();
|
||||
}
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
|
||||
// quick reverse geocoding to find the countries, using an offline asset
|
||||
Future<void> _locateCountries() async {
|
||||
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng).toSet());
|
||||
final newAddresses = <AddressDetails>[];
|
||||
todo.forEach((entry) {
|
||||
final position = entry.latLng;
|
||||
final countryCode = countryCodeMap.entries.firstWhere((kv) => kv.value.contains(position), orElse: () => null)?.key;
|
||||
entry.setCountry(countryCode);
|
||||
if (entry.hasAddress) {
|
||||
newAddresses.add(entry.addressDetails);
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
// full reverse geocoding, requiring Play Services and some connectivity
|
||||
Future<void> _locatePlaces() async {
|
||||
if (!(await availability.canLocatePlaces)) return;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress);
|
||||
final todo = byLocated[false] ?? [];
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
|
@ -55,6 +91,7 @@ mixin LocationMixin on SourceBase {
|
|||
final knownLocations = <Tuple2, AddressDetails>{};
|
||||
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
@ -65,12 +102,12 @@ mixin LocationMixin on SourceBase {
|
|||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
} else {
|
||||
await entry.locate(background: true);
|
||||
await entry.locatePlace(background: true);
|
||||
// it is intended to insert `null` if the geocoder failed,
|
||||
// so that we skip geocoding of following entries with the same coordinates
|
||||
knownLocations[latLng] = entry.addressDetails;
|
||||
}
|
||||
if (entry.isLocated) {
|
||||
if (entry.hasFineAddress) {
|
||||
newAddresses.add(entry.addressDetails);
|
||||
if (newAddresses.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
|
@ -80,9 +117,11 @@ mixin LocationMixin on SourceBase {
|
|||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
// debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
||||
void onAddressMetadataChanged() {
|
||||
|
@ -91,17 +130,23 @@ mixin LocationMixin on SourceBase {
|
|||
}
|
||||
|
||||
void updateLocations() {
|
||||
final locations = visibleEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
|
||||
sortedPlaces = List<String>.unmodifiable(locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
|
||||
final locations = visibleEntries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails).toList();
|
||||
final updatedPlaces = locations.map((address) => address.place).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
if (!listEquals(updatedPlaces, sortedPlaces)) {
|
||||
sortedPlaces = List.unmodifiable(updatedPlaces);
|
||||
eventBus.fire(PlacesChangedEvent());
|
||||
}
|
||||
|
||||
// the same country code could be found with different country names
|
||||
// e.g. if the locale changed between geolocating calls
|
||||
// so we merge countries by code, keeping only one name for each code
|
||||
final countriesByCode = Map.fromEntries(locations.map((address) => MapEntry(address.countryCode, address.countryName)).where((kv) => kv.key != null && kv.key.isNotEmpty));
|
||||
sortedCountries = List<String>.unmodifiable(countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase));
|
||||
|
||||
invalidateCountryFilterSummary();
|
||||
eventBus.fire(LocationsChangedEvent());
|
||||
final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase);
|
||||
if (!listEquals(updatedCountries, sortedCountries)) {
|
||||
sortedCountries = List.unmodifiable(updatedCountries);
|
||||
invalidateCountryFilterSummary();
|
||||
eventBus.fire(CountriesChangedEvent());
|
||||
}
|
||||
}
|
||||
|
||||
// filter summary
|
||||
|
@ -111,13 +156,16 @@ mixin LocationMixin on SourceBase {
|
|||
final Map<String, AvesEntry> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateCountryFilterSummary([Set<AvesEntry> entries]) {
|
||||
Set<String> countryCodes;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} else {
|
||||
final countryCodes = entries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails.countryCode).toSet();
|
||||
countryCodes = entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails.countryCode).toSet();
|
||||
countryCodes.remove(null);
|
||||
countryCodes.forEach(_filterEntryCountMap.remove);
|
||||
}
|
||||
eventBus.fire(CountrySummaryInvalidatedEvent(countryCodes));
|
||||
}
|
||||
|
||||
int countryEntryCount(LocationFilter filter) {
|
||||
|
@ -131,4 +179,12 @@ mixin LocationMixin on SourceBase {
|
|||
|
||||
class AddressMetadataChangedEvent {}
|
||||
|
||||
class LocationsChangedEvent {}
|
||||
class PlacesChangedEvent {}
|
||||
|
||||
class CountriesChangedEvent {}
|
||||
|
||||
class CountrySummaryInvalidatedEvent {
|
||||
final Set<String> countryCodes;
|
||||
|
||||
const CountrySummaryInvalidatedEvent(this.countryCodes);
|
||||
}
|
||||
|
|
|
@ -8,12 +8,11 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
|
||||
import 'package:pedantic/pedantic.dart';
|
||||
|
||||
class MediaStoreSource extends CollectionSource {
|
||||
bool _initialized = false;
|
||||
|
@ -27,7 +26,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
stateNotifier.value = SourceState.loading;
|
||||
await metadataDb.init();
|
||||
await favourites.init();
|
||||
final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone();
|
||||
final currentTimeZone = await TimeService.getDefaultTimeZone();
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
|
@ -103,25 +102,25 @@ class MediaStoreSource extends CollectionSource {
|
|||
updateDirectories();
|
||||
}
|
||||
|
||||
final analytics = FirebaseAnalytics();
|
||||
unawaited(analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString()));
|
||||
unawaited(analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString()));
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
await catalogEntries();
|
||||
unawaited(analytics.setUserProperty(name: 'tag_count', value: (ceilBy(sortedTags.length, 1)).toString()));
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
await locateEntries();
|
||||
unawaited(analytics.setUserProperty(name: 'country_count', value: (ceilBy(sortedCountries.length, 1)).toString()));
|
||||
|
||||
stateNotifier.value = SourceState.ready;
|
||||
|
||||
_reportCollectionDimensions();
|
||||
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}');
|
||||
},
|
||||
onError: (error) => debugPrint('$runtimeType stream error=$error'),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportCollectionDimensions() {
|
||||
final analytics = FirebaseAnalytics();
|
||||
analytics.setUserProperty(name: 'local_item_count', value: (ceilBy(allEntries.length, 3)).toString());
|
||||
analytics.setUserProperty(name: 'album_count', value: (ceilBy(rawAlbums.length, 1)).toString());
|
||||
analytics.setUserProperty(name: 'tag_count', value: (ceilBy(sortedTags.length, 1)).toString());
|
||||
analytics.setUserProperty(name: 'country_count', value: (ceilBy(sortedCountries.length, 1)).toString());
|
||||
}
|
||||
|
||||
// returns URIs to retry later. They could be URIs that are:
|
||||
// 1) currently being processed during bulk move/deletion
|
||||
// 2) registered in the Media Store but still being processed by their owner in a temporary location
|
||||
|
@ -132,7 +131,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
if (uri == null) return null;
|
||||
final idString = Uri.parse(uri).pathSegments.last;
|
||||
final pathSegments = Uri.parse(uri).pathSegments;
|
||||
// e.g. URI `content://media/` has no path segment
|
||||
if (pathSegments.isEmpty) return null;
|
||||
final idString = pathSegments.last;
|
||||
final contentId = int.tryParse(idString);
|
||||
if (contentId == null) return null;
|
||||
return MapEntry(contentId, uri);
|
||||
|
@ -175,13 +177,8 @@ class MediaStoreSource extends CollectionSource {
|
|||
addEntries(newEntries);
|
||||
await metadataDb.saveEntries(newEntries);
|
||||
cleanEmptyAlbums(existingDirectories);
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
await catalogEntries();
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
await locateEntries();
|
||||
|
||||
stateNotifier.value = SourceState.ready;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ mixin TagMixin on SourceBase {
|
|||
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
@ -55,11 +56,12 @@ mixin TagMixin on SourceBase {
|
|||
}
|
||||
|
||||
void updateTags() {
|
||||
final tags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
sortedTags = List.unmodifiable(tags);
|
||||
|
||||
invalidateTagFilterSummary();
|
||||
eventBus.fire(TagsChangedEvent());
|
||||
final updatedTags = visibleEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
|
||||
if (!listEquals(updatedTags, sortedTags)) {
|
||||
sortedTags = List.unmodifiable(updatedTags);
|
||||
invalidateTagFilterSummary();
|
||||
eventBus.fire(TagsChangedEvent());
|
||||
}
|
||||
}
|
||||
|
||||
// filter summary
|
||||
|
@ -69,13 +71,15 @@ mixin TagMixin on SourceBase {
|
|||
final Map<String, AvesEntry> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateTagFilterSummary([Set<AvesEntry> entries]) {
|
||||
Set<String> tags;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
} else {
|
||||
final tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
|
||||
tags = entries.where((entry) => entry.isCatalogued).expand((entry) => entry.xmpSubjects).toSet();
|
||||
tags.forEach(_filterEntryCountMap.remove);
|
||||
}
|
||||
eventBus.fire(TagSummaryInvalidatedEvent(tags));
|
||||
}
|
||||
|
||||
int tagEntryCount(TagFilter filter) {
|
||||
|
@ -90,3 +94,9 @@ mixin TagMixin on SourceBase {
|
|||
class CatalogMetadataChangedEvent {}
|
||||
|
||||
class TagsChangedEvent {}
|
||||
|
||||
class TagSummaryInvalidatedEvent {
|
||||
final Set<String> tags;
|
||||
|
||||
const TagSummaryInvalidatedEvent(this.tags);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ class MimeTypes {
|
|||
static const cr2 = 'image/x-canon-cr2';
|
||||
static const crw = 'image/x-canon-crw';
|
||||
static const dcr = 'image/x-kodak-dcr';
|
||||
static const djvu = 'image/vnd.djvu';
|
||||
static const dng = 'image/x-adobe-dng';
|
||||
static const erf = 'image/x-epson-erf';
|
||||
static const k25 = 'image/x-kodak-k25';
|
||||
|
|
|
@ -28,7 +28,7 @@ class AndroidDebugService {
|
|||
|
||||
static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available when decoding image bounds with `BitmapFactory`
|
||||
// returns map with all data available when decoding image bounds with `BitmapFactory`
|
||||
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
|
@ -41,7 +41,7 @@ class AndroidDebugService {
|
|||
|
||||
static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the content resolver
|
||||
// returns map with all data available from the content resolver
|
||||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
|
@ -55,7 +55,7 @@ class AndroidDebugService {
|
|||
|
||||
static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from the `ExifInterface` library
|
||||
// returns map with all data available from the `ExifInterface` library
|
||||
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
|
@ -70,7 +70,7 @@ class AndroidDebugService {
|
|||
|
||||
static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with all data available from `MediaMetadataRetriever`
|
||||
// returns map with all data available from `MediaMetadataRetriever`
|
||||
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
||||
'uri': entry.uri,
|
||||
}) as Map;
|
||||
|
@ -83,7 +83,7 @@ class AndroidDebugService {
|
|||
|
||||
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with the mime type and tag count for each directory found by `metadata-extractor`
|
||||
// returns map with the mime type and tag count for each directory found by `metadata-extractor`
|
||||
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
|
|
|
@ -52,20 +52,28 @@ class AndroidFileService {
|
|||
return;
|
||||
}
|
||||
|
||||
// returns a list of directories,
|
||||
// each directory is a map with "volumePath", "volumeDescription", "relativeDir"
|
||||
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
return (result as List).cast<Map>();
|
||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getRestrictedDirectories');
|
||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
static Future<bool> requestVolumeAccess(String volumePath) async {
|
||||
try {
|
||||
|
@ -87,7 +95,7 @@ class AndroidFileService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// return media URI
|
||||
// returns media URI
|
||||
static Future<Uri> scanFile(String path, String mimeType) async {
|
||||
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
||||
try {
|
||||
|
|
|
@ -86,8 +86,8 @@ class ImageFileService {
|
|||
bytesReceived += chunk.length;
|
||||
try {
|
||||
onBytesReceived(bytesReceived, expectedContentLength);
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} catch (error, stack) {
|
||||
completer.completeError(error, stack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -248,7 +248,7 @@ class ImageFileService {
|
|||
|
||||
static Future<Map> rename(AvesEntry entry, String newName) async {
|
||||
try {
|
||||
// return 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>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'newName': newName,
|
||||
|
@ -262,7 +262,7 @@ class ImageFileService {
|
|||
|
||||
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||
try {
|
||||
// return map with: 'rotationDegrees' 'isFlipped'
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'clockwise': clockwise,
|
||||
|
@ -276,7 +276,7 @@ class ImageFileService {
|
|||
|
||||
static Future<Map> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with: 'rotationDegrees' 'isFlipped'
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
}) as Map;
|
||||
|
|
|
@ -11,7 +11,7 @@ import 'package:flutter/services.dart';
|
|||
class MetadataService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/metadata');
|
||||
|
||||
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
static Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||
if (entry.isSvg) return null;
|
||||
|
||||
|
@ -33,7 +33,7 @@ class MetadataService {
|
|||
|
||||
Future<CatalogMetadata> call() async {
|
||||
try {
|
||||
// return map with:
|
||||
// returns map with:
|
||||
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
|
||||
// 'dateMillis': date taken in milliseconds since Epoch (long)
|
||||
// 'isAnimated': animated gif/webp (bool)
|
||||
|
@ -69,7 +69,7 @@ class MetadataService {
|
|||
if (entry.isSvg) return null;
|
||||
|
||||
try {
|
||||
// return map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
|
||||
// returns map with values for: 'aperture' (double), 'exposureTime' (description), 'focalLength' (double), 'iso' (int)
|
||||
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
|
@ -98,7 +98,7 @@ class MetadataService {
|
|||
|
||||
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||
try {
|
||||
// return map with values for:
|
||||
// returns map with values for:
|
||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||
// 'fullPanoWidth' (int), 'fullPanoHeight' (int)
|
||||
final result = await platform.invokeMethod('getPanoramaInfo', <String, dynamic>{
|
||||
|
|
|
@ -38,8 +38,8 @@ class ServicePolicy {
|
|||
() async {
|
||||
try {
|
||||
completer.complete(await platformCall());
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} catch (error, stack) {
|
||||
completer.completeError(error, stack);
|
||||
}
|
||||
_runningQueue.remove(key);
|
||||
_pickNext();
|
||||
|
|
|
@ -42,8 +42,8 @@ class SvgMetadataService {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (exception, stack) {
|
||||
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack');
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -78,8 +78,8 @@ class SvgMetadataService {
|
|||
if (docDir.isNotEmpty) docDirectory: docDir,
|
||||
if (metadataDir.isNotEmpty) metadataDirectory: metadataDir,
|
||||
};
|
||||
} catch (exception, stack) {
|
||||
debugPrint('failed to parse XML from SVG with exception=$exception\n$stack');
|
||||
} catch (error, stack) {
|
||||
debugPrint('failed to parse XML from SVG with error=$error\n$stack');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
15
lib/services/time_service.dart
Normal file
15
lib/services/time_service.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class TimeService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/time');
|
||||
|
||||
static Future<String> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ class ViewerService {
|
|||
|
||||
static Future<Map> getIntentData() async {
|
||||
try {
|
||||
// return nullable map with 'action' and possibly 'uri' 'mimeType'
|
||||
// returns nullable map with 'action' and possibly 'uri' 'mimeType'
|
||||
return await platform.invokeMethod('getIntentData') as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
|
|
16
lib/services/window_service.dart
Normal file
16
lib/services/window_service.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class WindowService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/window');
|
||||
|
||||
static Future<void> keepScreenOn(bool on) async {
|
||||
try {
|
||||
await platform.invokeMethod('keepScreenOn', <String, dynamic>{
|
||||
'on': on,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ class Durations {
|
|||
|
||||
// filter grids animations
|
||||
static const chipDecorationAnimation = Duration(milliseconds: 200);
|
||||
static const highlightScrollAnimationMinMillis = 400;
|
||||
static const highlightScrollAnimationMaxMillis = 2000;
|
||||
|
||||
// collection animations
|
||||
static const filterBarRemovalAnimation = Duration(milliseconds: 400);
|
||||
|
@ -44,6 +46,7 @@ class Durations {
|
|||
static const opToastDisplay = Duration(seconds: 2);
|
||||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
|
||||
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/services/android_app_service.dart';
|
|||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
@ -112,13 +113,13 @@ class Package {
|
|||
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
class StorageVolume {
|
||||
final String description, path, state;
|
||||
final bool isEmulated, isPrimary, isRemovable;
|
||||
final bool isPrimary, isRemovable;
|
||||
|
||||
const StorageVolume({
|
||||
this.description,
|
||||
this.isEmulated,
|
||||
this.isPrimary,
|
||||
this.isRemovable,
|
||||
this.path,
|
||||
|
@ -126,13 +127,59 @@ class StorageVolume {
|
|||
});
|
||||
|
||||
factory StorageVolume.fromMap(Map map) {
|
||||
final isPrimary = map['isPrimary'] ?? false;
|
||||
return StorageVolume(
|
||||
description: map['description'] ?? '',
|
||||
isEmulated: map['isEmulated'] ?? false,
|
||||
isPrimary: map['isPrimary'] ?? false,
|
||||
description: map['description'] ?? (isPrimary ? 'Internal storage' : 'SD card'),
|
||||
isPrimary: isPrimary,
|
||||
isRemovable: map['isRemovable'] ?? false,
|
||||
path: map['path'] ?? '',
|
||||
state: map['state'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class VolumeRelativeDirectory {
|
||||
final String volumePath, relativeDir;
|
||||
|
||||
const VolumeRelativeDirectory({
|
||||
this.volumePath,
|
||||
this.relativeDir,
|
||||
});
|
||||
|
||||
factory VolumeRelativeDirectory.fromMap(Map map) {
|
||||
return VolumeRelativeDirectory(
|
||||
volumePath: map['volumePath'],
|
||||
relativeDir: map['relativeDir'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// prefer static method over a null returning factory constructor
|
||||
static VolumeRelativeDirectory fromPath(String dirPath) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
if (volume == null) return null;
|
||||
|
||||
final root = volume.path;
|
||||
final rootLength = root.length;
|
||||
return VolumeRelativeDirectory(
|
||||
volumePath: root,
|
||||
relativeDir: dirPath.length < rootLength ? '' : dirPath.substring(rootLength),
|
||||
);
|
||||
}
|
||||
|
||||
String get directoryDescription => relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
||||
|
||||
String get volumeDescription {
|
||||
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
|
||||
return volume?.description ?? volumePath;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is VolumeRelativeDirectory && other.volumePath == volumePath && other.relativeDir == relativeDir;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(volumePath, relativeDir);
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@ class AChangeNotifier implements Listenable {
|
|||
for (final listener in localListeners) {
|
||||
try {
|
||||
if (_listeners.contains(listener)) listener();
|
||||
} catch (exception, stack) {
|
||||
debugPrint('$runtimeType failed to notify listeners with exception=$exception\n$stack');
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType failed to notify listeners with error=$error\n$stack');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
@ -8,9 +10,9 @@ class Constants {
|
|||
static const overflowStrutStyle = StrutStyle(height: 1.3);
|
||||
|
||||
static const titleTextStyle = TextStyle(
|
||||
color: Color(0xFFEEEEEE),
|
||||
fontSize: 20,
|
||||
fontFamily: 'Concourse Caps',
|
||||
fontWeight: FontWeight.w300,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
);
|
||||
|
||||
static const embossShadow = Shadow(
|
||||
|
@ -89,6 +91,12 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Country Code',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/denixport/dart.country',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Decorated Icon',
|
||||
license: 'MIT',
|
||||
|
@ -143,12 +151,6 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/flutter/flutter_markdown/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/flutter_markdown',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Native Timezone',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/pinkfish/flutter_native_timezone/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/pinkfish/flutter_native_timezone',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Staggered Animations',
|
||||
license: 'MIT',
|
||||
|
@ -257,12 +259,6 @@ class Constants {
|
|||
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/rrousselGit/provider',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Screen',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/clovisnicolas/flutter_screen/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/clovisnicolas/flutter_screen',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Shared Preferences',
|
||||
license: 'BSD 3-Clause',
|
||||
|
|
|
@ -3,6 +3,8 @@ class MimeUtils {
|
|||
switch (mime) {
|
||||
case 'image/x-icon':
|
||||
return 'ICO';
|
||||
case 'image/x-jg':
|
||||
return 'ART';
|
||||
case 'image/vnd.adobe.photoshop':
|
||||
case 'image/x-photoshop':
|
||||
return 'PSD';
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/flutter_version.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_logo.dart';
|
||||
|
@ -32,15 +34,19 @@ class _AppReferenceState extends State<AppReference> {
|
|||
}
|
||||
|
||||
Widget _buildAvesLine() {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold);
|
||||
final style = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.normal,
|
||||
letterSpacing: 1.0,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
);
|
||||
|
||||
return FutureBuilder<PackageInfo>(
|
||||
future: _packageInfoLoader,
|
||||
builder: (context, snapshot) {
|
||||
return LinkChip(
|
||||
leading: AvesLogo(
|
||||
size: style.fontSize * 1.25,
|
||||
size: style.fontSize * MediaQuery.textScaleFactorOf(context) * 1.25,
|
||||
),
|
||||
text: 'Aves ${snapshot.data?.version}',
|
||||
url: 'https://github.com/deckerst/aves',
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -13,25 +16,22 @@ class AboutCredits extends StatelessWidget {
|
|||
constraints: BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
'Credits',
|
||||
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
|
||||
),
|
||||
child: Text('Credits', style: Constants.titleTextStyle),
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: 'This app uses the font '),
|
||||
TextSpan(text: 'This app uses a TopoJSON file from'),
|
||||
WidgetSpan(
|
||||
child: LinkChip(
|
||||
text: 'Concourse',
|
||||
url: 'https://mbtype.com/fonts/concourse/',
|
||||
text: 'World Atlas',
|
||||
url: 'https://github.com/topojson/world-atlas',
|
||||
textStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
TextSpan(text: ' for titles and the media information page.'),
|
||||
TextSpan(text: 'under ISC License.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -94,10 +94,7 @@ class _LicensesState extends State<Licenses> {
|
|||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Open-Source Licenses',
|
||||
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
|
||||
),
|
||||
child: Text('Open-Source Licenses', style: Constants.titleTextStyle),
|
||||
),
|
||||
PopupMenuButton<LicenseSort>(
|
||||
itemBuilder: (context) => [
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/about/news_badge.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -45,10 +46,7 @@ class _AboutNewVersionState extends State<AboutNewVersion> {
|
|||
),
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'New Version Available',
|
||||
style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'),
|
||||
),
|
||||
TextSpan(text: 'New Version Available', style: Constants.titleTextStyle),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -48,9 +48,7 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
drawer: AppDrawer(
|
||||
source: collection.source,
|
||||
),
|
||||
drawer: AppDrawer(),
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -6,7 +6,7 @@ class EmptyContent extends StatelessWidget {
|
|||
final AlignmentGeometry alignment;
|
||||
|
||||
const EmptyContent({
|
||||
@required this.icon,
|
||||
this.icon,
|
||||
@required this.text,
|
||||
this.alignment = const FractionalOffset(.5, .35),
|
||||
});
|
||||
|
@ -19,18 +19,19 @@ class EmptyContent extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: color,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: color,
|
||||
),
|
||||
SizedBox(height: 16)
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 22,
|
||||
fontFamily: 'Concourse',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -7,8 +7,10 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
|
@ -63,6 +65,20 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
|
||||
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
|
||||
if (moveType == MoveType.move) {
|
||||
// check whether moving is possible given OS restrictions,
|
||||
// before asking to pick a destination album
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
for (final selectionDir in selectionDirs) {
|
||||
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
|
||||
if (restrictedDirs.contains(dir)) {
|
||||
await showRestrictedDirectoryDialog(context, dir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final destinationAlbum = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<String>(
|
||||
|
@ -73,7 +89,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
||||
|
||||
if (!await checkStoragePermission(context, selection)) return;
|
||||
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return;
|
||||
|
||||
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
|
||||
|
||||
|
|
|
@ -155,7 +155,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
|||
toggledNotifier: _highlightedNotifier,
|
||||
startAngle: pi * -3 / 4,
|
||||
centerSweep: false,
|
||||
onSweepEnd: () => highlightInfo.remove(entry),
|
||||
onSweepEnd: highlightInfo.clear,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import 'package:aves/widgets/common/extensions/media_query.dart';
|
|||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -44,105 +43,103 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HighlightInfoProvider(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = constraints.biggest;
|
||||
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
|
||||
if (viewportSize.isEmpty) return SizedBox.shrink();
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final viewportSize = constraints.biggest;
|
||||
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
|
||||
if (viewportSize.isEmpty) return SizedBox.shrink();
|
||||
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: context.currentRouteName,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||
final scrollController = PrimaryScrollController.of(context);
|
||||
final tileExtentManager = TileExtentManager(
|
||||
settingsRouteKey: context.currentRouteName,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||
final scrollController = PrimaryScrollController.of(context);
|
||||
|
||||
// do not replace by Provider.of<CollectionLens>
|
||||
// so that view updates on collection filter changes
|
||||
return Consumer<CollectionLens>(
|
||||
builder: (context, collection, child) {
|
||||
final scrollView = AnimationLimiter(
|
||||
child: CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
collection: collection,
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
collection: collection,
|
||||
),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
cacheExtent: cacheExtent,
|
||||
),
|
||||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<AvesEntry>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
// do not replace by Provider.of<CollectionLens>
|
||||
// so that view updates on collection filter changes
|
||||
return Consumer<CollectionLens>(
|
||||
builder: (context, collection, child) {
|
||||
final scrollView = AnimationLimiter(
|
||||
child: CollectionScrollView(
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
// painting the thumbnail half-border on top of the grid yields artifacts,
|
||||
// so we use a `foregroundPainter` to cover them instead
|
||||
foregroundPainter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
strokeWidth: DecoratedThumbnail.borderWidth * 2,
|
||||
color: DecoratedThumbnail.borderColor,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => context.read<HighlightInfo>().add(entry),
|
||||
child: scrollView,
|
||||
);
|
||||
|
||||
final selector = GridSelectionGestureDetector(
|
||||
selectable: AvesApp.mode == AppMode.main,
|
||||
collection: collection,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
child: scaler,
|
||||
);
|
||||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
|
||||
appBar: CollectionAppBar(
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
collection: collection,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
tileExtent: tileExtent,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
),
|
||||
child: selector,
|
||||
),
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: scrollController,
|
||||
cacheExtent: cacheExtent,
|
||||
),
|
||||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<AvesEntry>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
// painting the thumbnail half-border on top of the grid yields artifacts,
|
||||
// so we use a `foregroundPainter` to cover them instead
|
||||
foregroundPainter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
spacing: tileExtentManager.spacing,
|
||||
strokeWidth: DecoratedThumbnail.borderWidth * 2,
|
||||
color: DecoratedThumbnail.borderColor,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
),
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
|
||||
child: scrollView,
|
||||
);
|
||||
|
||||
final selector = GridSelectionGestureDetector(
|
||||
selectable: AvesApp.mode == AppMode.main,
|
||||
collection: collection,
|
||||
scrollController: scrollController,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
child: scaler,
|
||||
);
|
||||
|
||||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: viewportSize.width,
|
||||
tileExtent: tileExtent,
|
||||
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
|
||||
tileBuilder: (entry) => InteractiveThumbnail(
|
||||
key: ValueKey(entry.contentId),
|
||||
collection: collection,
|
||||
entry: entry,
|
||||
tileExtent: tileExtent,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
),
|
||||
child: selector,
|
||||
),
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -87,10 +87,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
|
||||
opStream.listen(
|
||||
processed.add,
|
||||
onError: (error) {
|
||||
debugPrint('_showOpReport error=$error');
|
||||
onComplete();
|
||||
},
|
||||
onError: (error) => debugPrint('_showOpReport error=$error'),
|
||||
onDone: onComplete,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -9,24 +10,26 @@ mixin PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
while (true) {
|
||||
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs == null) return false;
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
final dir = dirs.first;
|
||||
final volumePath = dir['volumePath'] as String;
|
||||
final volumeDescription = dir['volumeDescription'] as String;
|
||||
final relativeDir = dir['relativeDir'] as String;
|
||||
final dirDisplayName = relativeDir.isEmpty ? 'root' : '“$relativeDir”';
|
||||
final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null);
|
||||
if (restrictedInaccessibleDir != null) {
|
||||
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
final dir = dirs.first;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
title: 'Storage Volume Access',
|
||||
content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'),
|
||||
content: Text('Please select the ${dir.directoryDescription} directory of “${dir.volumeDescription}” in the next screen, so that this app can access it and complete your request.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -43,11 +46,30 @@ mixin PermissionAwareMixin {
|
|||
// abort if the user cancels in Flutter
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
final granted = await AndroidFileService.requestVolumeAccess(volumePath);
|
||||
final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
|
||||
if (!granted) {
|
||||
// abort if the user denies access from the native dialog
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> showRestrictedDirectoryDialog(BuildContext context, VolumeRelativeDirectory dir) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
title: 'Restricted Access',
|
||||
content: Text('This app is not allowed to modify files in the ${dir.directoryDescription} directory of “${dir.volumeDescription}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('OK'.toUpperCase()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,23 +7,16 @@ import 'package:provider/provider.dart';
|
|||
// - a vertically scrollable body.
|
||||
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
|
||||
class BottomGestureAreaProtector extends StatelessWidget {
|
||||
// as of Flutter v1.22.5, `systemGestureInsets` from `MediaQuery` mistakenly reports no bottom inset,
|
||||
// so we use an empirical measurement instead
|
||||
static const double systemGestureInsetsBottom = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.effectiveBottomPadding,
|
||||
builder: (c, mqPaddingBottom, child) {
|
||||
// devices with physical navigation buttons have no bottom insets
|
||||
// we assume these devices do not use gesture navigation
|
||||
if (mqPaddingBottom == 0) return SizedBox();
|
||||
selector: (c, mq) => mq.systemGestureInsets.bottom,
|
||||
builder: (c, systemGestureBottom, child) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: systemGestureInsetsBottom,
|
||||
height: systemGestureBottom,
|
||||
child: AbsorbPointer(),
|
||||
);
|
||||
},
|
||||
|
|
|
@ -40,7 +40,14 @@ class LinkChip extends StatelessWidget {
|
|||
leading,
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
Text(text),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) => Icon(
|
||||
|
|
|
@ -15,17 +15,19 @@ class MenuRow extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = IconTheme.of(context).size * textScaleFactor;
|
||||
return Row(
|
||||
children: [
|
||||
if (checked != null) ...[
|
||||
Opacity(
|
||||
opacity: checked ? 1 : 0,
|
||||
child: Icon(AIcons.checked),
|
||||
child: Icon(AIcons.checked, size: iconSize),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
if (icon != null) ...[
|
||||
Icon(icon),
|
||||
Icon(icon, size: iconSize),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: Text(text)),
|
||||
|
|
|
@ -26,7 +26,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider0<SectionedListLayout<T>>(
|
||||
update: (context, __) => _updateLayouts(context),
|
||||
update: (context, _) => _updateLayouts(context),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
typedef FilterCallback = void Function(CollectionFilter filter);
|
||||
typedef OffsetFilterCallback = void Function(CollectionFilter filter, Offset tapPosition);
|
||||
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
|
||||
|
||||
enum HeroType { always, onTap, never }
|
||||
|
||||
|
@ -26,7 +35,6 @@ class AvesFilterChip extends StatefulWidget {
|
|||
static const double minChipHeight = kMinInteractiveDimension;
|
||||
static const double minChipWidth = 80;
|
||||
static const double maxChipWidth = 160;
|
||||
static const double iconSize = 20;
|
||||
|
||||
const AvesFilterChip({
|
||||
Key key,
|
||||
|
@ -39,10 +47,43 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.padding = 6.0,
|
||||
this.heroType = HeroType.onTap,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onLongPress = showDefaultLongPressMenu,
|
||||
}) : assert(filter != null),
|
||||
super(key: key);
|
||||
|
||||
static Future<void> showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
|
||||
if (AvesApp.mode == AppMode.main) {
|
||||
final actions = [
|
||||
if (filter is AlbumFilter) ChipAction.goToAlbumPage,
|
||||
if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage,
|
||||
if (filter is TagFilter) ChipAction.goToTagPage,
|
||||
ChipAction.hide,
|
||||
];
|
||||
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
// after the user is done with the popup menu
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||
final touchArea = Size(40, 40);
|
||||
// TODO TLAD check menu is within safe area, when this lands on stable: https://github.com/flutter/flutter/commit/cfc8ec23b633da1001359e384435e8333c9d3733
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
items: actions
|
||||
.map((action) => PopupMenuItem(
|
||||
value: action,
|
||||
child: MenuRow(text: action.getText(), icon: action.getIcon()),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
if (selectedAction != null) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => ChipActionDelegate().onActionSelected(context, filter, selectedAction));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
_AvesFilterChipState createState() => _AvesFilterChipState();
|
||||
}
|
||||
|
@ -88,7 +129,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const iconSize = AvesFilterChip.iconSize;
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = 20 * textScaleFactor;
|
||||
|
||||
final hasBackground = widget.background != null;
|
||||
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
||||
|
@ -178,7 +220,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
setState(() => _tapped = true);
|
||||
}
|
||||
: null,
|
||||
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null,
|
||||
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: FutureBuilder<Color>(
|
||||
future: _colorFuture,
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:aves/utils/android_file_utils.dart';
|
|||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class VideoIcon extends StatelessWidget {
|
||||
final AvesEntry entry;
|
||||
|
@ -172,10 +173,26 @@ class IconUtils {
|
|||
static Widget getAlbumIcon({
|
||||
@required BuildContext context,
|
||||
@required String album,
|
||||
double size = 24,
|
||||
double size,
|
||||
bool embossed = false,
|
||||
}) {
|
||||
Widget buildIcon(IconData icon) => embossed ? DecoratedIcon(icon, shadows: [Constants.embossShadow], size: size) : Icon(icon, size: size);
|
||||
size ??= IconTheme.of(context).size;
|
||||
Widget buildIcon(IconData icon) => embossed
|
||||
? MediaQuery(
|
||||
// `DecoratedIcon` internally uses `Text`,
|
||||
// which size depends on the ambient `textScaleFactor`
|
||||
// but we already accommodate for it upstream
|
||||
data: context.read<MediaQueryData>().copyWith(textScaleFactor: 1.0),
|
||||
child: DecoratedIcon(
|
||||
icon,
|
||||
shadows: [Constants.embossShadow],
|
||||
size: size,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
icon,
|
||||
size: size,
|
||||
);
|
||||
switch (androidFileUtils.getAlbumType(album)) {
|
||||
case AlbumType.camera:
|
||||
return buildIcon(AIcons.cameraAlbum);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -29,7 +31,8 @@ class HighlightTitle extends StatelessWidget {
|
|||
)
|
||||
],
|
||||
fontSize: fontSize,
|
||||
fontFamily: 'Concourse Caps',
|
||||
letterSpacing: 1.0,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
);
|
||||
|
||||
return Align(
|
||||
|
|
|
@ -14,20 +14,17 @@ import 'package:aves/widgets/debug/storage.dart';
|
|||
import 'package:aves/widgets/viewer/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AppDebugPage extends StatefulWidget {
|
||||
static const routeName = '/debug';
|
||||
|
||||
final CollectionSource source;
|
||||
|
||||
const AppDebugPage({this.source});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _AppDebugPageState();
|
||||
}
|
||||
|
||||
class _AppDebugPageState extends State<AppDebugPage> {
|
||||
CollectionSource get source => widget.source;
|
||||
CollectionSource get source => context.read<CollectionSource>();
|
||||
|
||||
Set<AvesEntry> get visibleEntries => source.visibleEntries;
|
||||
|
||||
|
@ -63,7 +60,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
|||
Widget _buildGeneralTabView() {
|
||||
final catalogued = visibleEntries.where((entry) => entry.isCatalogued);
|
||||
final withGps = catalogued.where((entry) => entry.hasGps);
|
||||
final located = withGps.where((entry) => entry.isLocated);
|
||||
final withAddress = withGps.where((entry) => entry.hasAddress);
|
||||
final withFineAddress = withGps.where((entry) => entry.hasFineAddress);
|
||||
return AvesExpansionTile(
|
||||
title: 'General',
|
||||
children: [
|
||||
|
@ -104,7 +102,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
|
|||
'Visible entries': '${visibleEntries.length}',
|
||||
'Catalogued': '${catalogued.length}',
|
||||
'With GPS': '${withGps.length}',
|
||||
'With address': '${located.length}',
|
||||
'With address': '${withAddress.length}',
|
||||
'With fine address': '${withFineAddress.length}',
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
|
@ -40,7 +40,6 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
|
|||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: InfoRowGroup({
|
||||
'description': '${v.description}',
|
||||
'isEmulated': '${v.isEmulated}',
|
||||
'isPrimary': '${v.isPrimary}',
|
||||
'isRemovable': '${v.isRemovable}',
|
||||
'state': '${v.state}',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -76,8 +78,8 @@ class DialogTitle extends StatelessWidget {
|
|||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Concourse Caps',
|
||||
fontWeight: FontWeight.normal,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
@ -40,39 +41,29 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final volumeTiles = <Widget>[];
|
||||
if (_allVolumes.length > 1) {
|
||||
final byPrimary = groupBy<StorageVolume, bool>(_allVolumes, (volume) => volume.isPrimary);
|
||||
int compare(StorageVolume a, StorageVolume b) => compareAsciiUpperCase(a.path, b.path);
|
||||
final primaryVolumes = byPrimary[true]..sort(compare);
|
||||
final otherVolumes = byPrimary[false]..sort(compare);
|
||||
volumeTiles.addAll([
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
|
||||
child: Text('Storage:'),
|
||||
),
|
||||
...primaryVolumes.map(_buildVolumeTile),
|
||||
...otherVolumes.map(_buildVolumeTile),
|
||||
SizedBox(height: 8),
|
||||
]);
|
||||
}
|
||||
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
title: 'New Album',
|
||||
scrollController: _scrollController,
|
||||
scrollableContent: [
|
||||
if (_allVolumes.length > 1) ...[
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(top: 20),
|
||||
child: Text('Storage:'),
|
||||
),
|
||||
..._allVolumes.map((volume) => RadioListTile<StorageVolume>(
|
||||
value: volume,
|
||||
groupValue: _selectedVolume,
|
||||
onChanged: (volume) {
|
||||
_selectedVolume = volume;
|
||||
_validate();
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
volume.description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
volume.path,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
)),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
...volumeTiles,
|
||||
Padding(
|
||||
padding: AvesDialog.contentHorizontalPadding + EdgeInsets.only(bottom: 8),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
|
@ -83,7 +74,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
focusNode: _nameFieldFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Album name',
|
||||
helperText: exists ? 'Album already exists' : '',
|
||||
helperText: exists ? 'Directory already exists' : '',
|
||||
),
|
||||
autofocus: _allVolumes.length == 1,
|
||||
onChanged: (_) => _validate(),
|
||||
|
@ -110,6 +101,28 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildVolumeTile(StorageVolume volume) => RadioListTile<StorageVolume>(
|
||||
value: volume,
|
||||
groupValue: _selectedVolume,
|
||||
onChanged: (volume) {
|
||||
_selectedVolume = volume;
|
||||
_validate();
|
||||
setState(() {});
|
||||
},
|
||||
title: Text(
|
||||
volume.description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
volume.path,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
|
||||
void _onFocus() async {
|
||||
// when the field gets focus, we wait for the soft keyboard to appear
|
||||
// then scroll to the bottom to make sure the field is in view
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue