Merge branch 'develop'
This commit is contained in:
commit
da4d79a6f8
223 changed files with 4106 additions and 1950 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '2.2.3'
|
||||
flutter-version: '2.5.1'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '2.2.3'
|
||||
flutter-version: '2.5.1'
|
||||
|
||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_2.2.3.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.2.3.sksl.json
|
||||
flutter build apk --bundle-sksl-path shaders_2.5.1.sksl.json
|
||||
flutter build appbundle --bundle-sksl-path shaders_2.5.1.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.5.2] - 2021-09-29
|
||||
### Added
|
||||
- Map: show items for bounds, open items in viewer, tap gesture to toggle fullscreen
|
||||
- Info: remove metadata (Exif, XMP, etc.)
|
||||
- Accessibility: support "time to take action" and "remove animations" settings
|
||||
|
||||
### Changed
|
||||
- upgraded Flutter to stable v2.5.1
|
||||
- faster collection loading when launching the app
|
||||
- Collection: changed color & scale of thumbnail icons to match text
|
||||
- Albums / Countries / Tags: changed layout, with label below cover
|
||||
|
||||
### Fixed
|
||||
- album bookmarks & pins were reset when rescanning items
|
||||
|
||||
## [v1.5.1] - 2021-09-08
|
||||
### Added
|
||||
- About: bug reporting instructions
|
||||
|
@ -71,7 +86,7 @@ All notable changes to this project will be documented in this file.
|
|||
### Changed
|
||||
- improved SVG support with a different rendering engine
|
||||
- changed logo
|
||||
- upgraded flutter to stable v2.2.3
|
||||
- upgraded Flutter to stable v2.2.3
|
||||
- migrated to sound null safety
|
||||
- viewer: parallax effect when scrolling
|
||||
|
||||
|
|
|
@ -120,10 +120,10 @@ dependencies {
|
|||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
|
||||
// https://jitpack.io/com/github/deckerst/Android-TiffBitmapFactory/**********/build.log
|
||||
// https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
|
||||
// https://jitpack.io/com/github/deckerst/pixymeta-android/**********/build.log
|
||||
implementation 'com.github.deckerst:pixymeta-android:e4e50da939' // forked, built by JitPack
|
||||
// https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:082ed1dafc' // forked, built by JitPack
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.2.0'
|
||||
|
|
|
@ -52,18 +52,18 @@ class MainActivity : FlutterActivity() {
|
|||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
// dart -> platform -> dart
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
|
||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
|
|
|
@ -118,13 +118,13 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri =
|
||||
throw UnsupportedOperationException()
|
||||
throw UnsupportedOperationException("`insert` is not supported by this content provider")
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int =
|
||||
throw UnsupportedOperationException()
|
||||
throw UnsupportedOperationException("`delete` is not supported by this content provider")
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int =
|
||||
throw UnsupportedOperationException()
|
||||
throw UnsupportedOperationException("`update` is not supported by this content provider")
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>()
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
||||
class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved)
|
||||
"hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts)
|
||||
"getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun areAnimationsRemoved(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
var removed = false
|
||||
try {
|
||||
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings", e)
|
||||
}
|
||||
result.success(removed)
|
||||
}
|
||||
|
||||
private fun hasRecommendedTimeouts(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
}
|
||||
|
||||
private fun getRecommendedTimeoutMillis(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
result.error("getRecommendedTimeoutMillis-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
|
||||
return
|
||||
}
|
||||
|
||||
val originalTimeoutMillis = call.argument<Int>("originalTimeoutMillis")
|
||||
val content = call.argument<List<String>>("content")
|
||||
if (originalTimeoutMillis == null || content == null) {
|
||||
result.error("getRecommendedTimeoutMillis-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
var uiContentFlags = 0
|
||||
content.forEach {
|
||||
uiContentFlags = when (it) {
|
||||
"controls" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_CONTROLS
|
||||
"icons" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_ICONS
|
||||
"text" -> uiContentFlags or AccessibilityManager.FLAG_CONTENT_TEXT
|
||||
else -> {
|
||||
result.error("getRecommendedTimeoutMillis-flag", "unsupported UI content flag=$it", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val am = activity.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
|
||||
if (am == null) {
|
||||
result.error("getRecommendedTimeoutMillis-service", "failed to get accessibility manager", null)
|
||||
return
|
||||
}
|
||||
|
||||
val millis = am.getRecommendedTimeoutMillis(originalTimeoutMillis, uiContentFlags)
|
||||
result.success(millis)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<AccessibilityHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/accessibility"
|
||||
}
|
||||
}
|
|
@ -4,17 +4,25 @@ import android.content.*
|
|||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.R
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -39,6 +47,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
"openMap" -> safe(call, result, ::openMap)
|
||||
"setAs" -> safe(call, result, ::setAs)
|
||||
"share" -> safe(call, result, ::share)
|
||||
"canPin" -> safe(call, result, ::canPin)
|
||||
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -92,7 +102,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
private suspend fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
val sizeDip = call.argument<Double>("sizeDip")
|
||||
val sizeDip = call.argument<Number>("sizeDip")?.toDouble()
|
||||
if (packageName == null || sizeDip == null) {
|
||||
result.error("getAppIcon-args", "failed because of missing arguments", null)
|
||||
return
|
||||
|
@ -307,6 +317,64 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
// shortcuts
|
||||
|
||||
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
|
||||
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(isPinSupported())
|
||||
}
|
||||
|
||||
private fun pin(call: MethodCall, result: MethodChannel.Result) {
|
||||
val label = call.argument<String>("label")
|
||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||
val filters = call.argument<List<String>>("filters")
|
||||
if (label == null || filters == null) {
|
||||
result.error("pin-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPinSupported()) {
|
||||
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
|
||||
return
|
||||
}
|
||||
|
||||
var icon: IconCompat? = null
|
||||
if (iconBytes?.isNotEmpty() == true) {
|
||||
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
|
||||
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256)
|
||||
if (bitmap != null) {
|
||||
// adaptive, so the bitmap is used as background and covers the whole icon
|
||||
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
if (icon == null) {
|
||||
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
|
||||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
|
||||
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
|
||||
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
|
||||
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
|
||||
.setShortLabel(label)
|
||||
.setIcon(icon)
|
||||
.setIntent(intent)
|
||||
.build()
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<AppAdapterHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/app"
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.R
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"canPin" -> safe(call, result, ::canPin)
|
||||
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
|
||||
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(isSupported())
|
||||
}
|
||||
|
||||
private fun pin(call: MethodCall, result: MethodChannel.Result) {
|
||||
val label = call.argument<String>("label")
|
||||
val iconBytes = call.argument<ByteArray>("iconBytes")
|
||||
val filters = call.argument<List<String>>("filters")
|
||||
if (label == null || filters == null) {
|
||||
result.error("pin-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupported()) {
|
||||
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
|
||||
return
|
||||
}
|
||||
|
||||
var icon: IconCompat? = null
|
||||
if (iconBytes?.isNotEmpty() == true) {
|
||||
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
|
||||
bitmap = centerSquareCrop(context, bitmap, 256)
|
||||
if (bitmap != null) {
|
||||
// adaptive, so the bitmap is used as background and covers the whole icon
|
||||
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
if (icon == null) {
|
||||
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
|
||||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
|
||||
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
|
||||
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
|
||||
val shortcut = ShortcutInfoCompat.Builder(context, UUID.randomUUID().toString())
|
||||
.setShortLabel(label)
|
||||
.setIcon(icon)
|
||||
.setIntent(intent)
|
||||
.build()
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/shortcut"
|
||||
}
|
||||
}
|
|
@ -20,10 +20,10 @@ import deckers.thibault.aves.metadata.Metadata
|
|||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
|
@ -60,31 +60,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getPixyMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupportedByPixyMeta(mimeType)) {
|
||||
result.error("getPixyMetadata-unsupported", "PixyMeta does not support mimeType=$mimeType", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
metadataMap.putAll(PixyMetaHelper.describe(input))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
|
||||
return
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val dirs = hashMapOf(
|
||||
"cacheDir" to context.cacheDir,
|
||||
|
@ -206,7 +181,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val metadataMap = HashMap<String, String?>()
|
||||
if (isSupportedByExifInterface(mimeType, strict = false)) {
|
||||
if (canReadWithExifInterface(mimeType, strict = false)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val exif = ExifInterface(input)
|
||||
|
@ -258,7 +233,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -290,6 +265,28 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getPixyMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val metadataMap = HashMap<String, String>()
|
||||
if (canReadWithPixyMeta(mimeType)) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
metadataMap.putAll(PixyMetaHelper.describe(input))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("getPixyMetadata-exception", e.message, e.stackTraceToString())
|
||||
return
|
||||
}
|
||||
}
|
||||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
|
|
@ -5,15 +5,21 @@ 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
|
||||
import java.util.*
|
||||
|
||||
class DeviceHandler : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().id)
|
||||
}
|
||||
|
||||
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
||||
|
|
|
@ -27,8 +27,8 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -62,7 +62,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val thumbnails = ArrayList<ByteArray>()
|
||||
if (isSupportedByExifInterface(mimeType)) {
|
||||
if (canReadWithExifInterface(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
@ -150,7 +150,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -217,7 +217,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(resultFields)
|
||||
}
|
||||
|
||||
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
override fun onFailure(throwable: Throwable) = result.error("copyEmbeddedBytes-failure", "failed to get entry for uri=$uri mime=$mimeType", throwable.message)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -10,7 +10,6 @@ import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
|||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
|
@ -24,7 +23,7 @@ import kotlinx.coroutines.GlobalScope
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||
class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||
private val density = activity.resources.displayMetrics.density
|
||||
|
||||
private val regionFetcher = RegionFetcher(activity)
|
||||
|
@ -36,9 +35,6 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
|
||||
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -60,7 +56,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -70,10 +66,10 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
|
||||
val rotationDegrees = call.argument<Int>("rotationDegrees")
|
||||
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||
val widthDip = call.argument<Double>("widthDip")
|
||||
val heightDip = call.argument<Double>("heightDip")
|
||||
val widthDip = call.argument<Number>("widthDip")?.toDouble()
|
||||
val heightDip = call.argument<Number>("heightDip")?.toDouble()
|
||||
val pageId = call.argument<Int>("pageId")
|
||||
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
||||
val defaultSizeDip = call.argument<Number>("defaultSizeDip")?.toDouble()
|
||||
|
||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||
result.error("getThumbnail-args", "failed because of missing arguments", null)
|
||||
|
@ -162,7 +158,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
destinationDir = ensureTrailingSeparator(destinationDir)
|
||||
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -190,79 +186,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
|
||||
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
})
|
||||
}
|
||||
|
||||
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val clockwise = call.argument<Boolean>("clockwise")
|
||||
if (clockwise == null) {
|
||||
result.error("rotate-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
|
||||
changeOrientation(call, result, op)
|
||||
}
|
||||
|
||||
private fun flip(call: MethodCall, result: MethodChannel.Result) {
|
||||
changeOrientation(call, result, ExifOrientationOp.FLIP)
|
||||
}
|
||||
|
||||
private fun changeOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null) {
|
||||
result.error("changeOrientation-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("changeOrientation-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("changeOrientation-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
})
|
||||
}
|
||||
|
||||
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
|
||||
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
|
||||
val fields = call.argument<List<String>>("fields")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null || fields == null) {
|
||||
result.error("editDate-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("editDate-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date", "${throwable.message}\n${throwable.stackTraceToString()}")
|
||||
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -272,6 +196,6 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/image"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_file"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -15,6 +17,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
|
||||
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) }
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -37,7 +40,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
|
|||
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById))
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/mediastore"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_store"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
|
||||
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val clockwise = call.argument<Boolean>("clockwise")
|
||||
if (clockwise == null) {
|
||||
result.error("rotate-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
|
||||
editOrientation(call, result, op)
|
||||
}
|
||||
|
||||
private fun flip(call: MethodCall, result: MethodChannel.Result) {
|
||||
editOrientation(call, result, ExifOrientationOp.FLIP)
|
||||
}
|
||||
|
||||
private fun editOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null) {
|
||||
result.error("editOrientation-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("editOrientation-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editOrientation-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.editOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun editDate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val dateMillis = call.argument<Number>("dateMillis")?.toLong()
|
||||
val shiftMinutes = call.argument<Number>("shiftMinutes")?.toLong()
|
||||
val fields = call.argument<List<String>>("fields")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null || fields == null) {
|
||||
result.error("editDate-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("editDate-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("editDate-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.editDate(activity, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
||||
val types = call.argument<List<String>>("types")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null || types == null) {
|
||||
result.error("removeTypes-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("removeTypes-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("removeTypes-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.removeMetadataTypes(activity, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata_edit"
|
||||
}
|
||||
}
|
|
@ -54,8 +54,8 @@ import deckers.thibault.aves.utils.LogUtils
|
|||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -70,7 +70,7 @@ import java.text.ParseException
|
|||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
|
||||
|
@ -97,7 +97,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
var foundExif = false
|
||||
var foundXmp = false
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -225,7 +225,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
if (!foundExif && canReadWithExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
|
@ -337,7 +337,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
|
||||
var foundExif = false
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -480,7 +480,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
if (!foundExif && canReadWithExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
|
@ -584,7 +584,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
var foundExif = false
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -603,7 +603,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (!foundExif && isSupportedByExifInterface(mimeType)) {
|
||||
if (!foundExif && canReadWithExifInterface(mimeType)) {
|
||||
// fallback to read EXIF via ExifInterface
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
|
@ -654,7 +654,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if (isSupportedByMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
|
@ -755,8 +755,8 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MetadataHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||
private val LOG_TAG = LogUtils.createTag<MetadataFetchHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/metadata_fetch"
|
||||
|
||||
private val allMetadataRedundantDirNames = setOf(
|
||||
"MP4",
|
|
@ -1,8 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
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
|
||||
|
@ -30,7 +28,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -158,12 +155,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(deleted)
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/storage"
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
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
|
||||
import java.util.*
|
||||
|
||||
class TimeHandler : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/time"
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -96,7 +96,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri, mimeType)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
} else if (!canDecodeWithFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
|
||||
} else {
|
||||
|
@ -187,7 +187,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ImageByteStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/image_byte_stream"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
||||
|
||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||
|
||||
|
|
|
@ -177,6 +177,6 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/image_op_stream"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_op_stream"
|
||||
}
|
||||
}
|
|
@ -59,6 +59,6 @@ class MediaStoreChangeStreamHandler(private val context: Context) : EventChannel
|
|||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreChangeStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/mediastorechange"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_store_change"
|
||||
}
|
||||
}
|
|
@ -62,6 +62,6 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/mediastorestream"
|
||||
const val CHANNEL = "deckers.thibault/aves/media_store_stream"
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
|
||||
private val contentObserver = object : ContentObserver(null) {
|
||||
private var accelerometerRotation: Int = 0
|
||||
private var transitionAnimationScale: Float = 1f
|
||||
|
||||
init {
|
||||
update()
|
||||
|
@ -33,7 +34,8 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
if (update()) {
|
||||
success(
|
||||
hashMapOf(
|
||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation
|
||||
Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
|
||||
Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -47,6 +49,12 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
accelerometerRotation = newAccelerometerRotation
|
||||
changed = true
|
||||
}
|
||||
val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
|
||||
if (transitionAnimationScale != newTransitionAnimationScale) {
|
||||
transitionAnimationScale = newTransitionAnimationScale
|
||||
changed = true
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get settings", e)
|
||||
}
|
||||
|
@ -83,6 +91,6 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
|
|||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<SettingsChangeStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/settingschange"
|
||||
const val CHANNEL = "deckers.thibault/aves/settings_change"
|
||||
}
|
||||
}
|
|
@ -32,6 +32,17 @@ object Metadata {
|
|||
const val DIR_MEDIA = "Media" // custom
|
||||
const val DIR_COVER_ART = "Cover" // custom
|
||||
|
||||
// types of metadata
|
||||
const val TYPE_EXIF = "exif"
|
||||
const val TYPE_ICC_PROFILE = "icc_profile"
|
||||
const val TYPE_IPTC = "iptc"
|
||||
const val TYPE_JFIF = "jfif"
|
||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||
const val TYPE_JPEG_COMMENT = "jpeg_comment"
|
||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||
const val TYPE_XMP = "xmp"
|
||||
|
||||
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
|
||||
|
|
|
@ -33,7 +33,10 @@ object MetadataExtractorHelper {
|
|||
}
|
||||
|
||||
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
|
||||
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
|
||||
if (this.containsTag(tag)) {
|
||||
val date = this.getDate(tag, null, TimeZone.getDefault())
|
||||
if (date != null) save(date.time)
|
||||
}
|
||||
}
|
||||
|
||||
// geotiff
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||
import pixy.meta.meta.Metadata
|
||||
import pixy.meta.meta.MetadataEntry
|
||||
import pixy.meta.meta.MetadataType
|
||||
|
@ -54,4 +63,22 @@ object PixyMetaHelper {
|
|||
fun XMP.xmpDocString(): String = XMLUtils.serializeToString(xmpDocument)
|
||||
|
||||
fun XMP.extendedXmpDocString(): String = XMLUtils.serializeToString(extendedXmpDocument)
|
||||
|
||||
fun removeMetadata(input: InputStream, output: OutputStream, metadataTypes: Set<String>) {
|
||||
val types = metadataTypes.map(::toMetadataType).toTypedArray()
|
||||
Metadata.removeMetadata(input, output, *types)
|
||||
}
|
||||
|
||||
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
|
||||
TYPE_EXIF -> MetadataType.EXIF
|
||||
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
|
||||
TYPE_IPTC -> MetadataType.IPTC
|
||||
TYPE_JFIF -> MetadataType.JPG_JFIF
|
||||
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
|
||||
TYPE_JPEG_COMMENT -> MetadataType.COMMENT
|
||||
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
|
||||
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
|
||||
TYPE_XMP -> MetadataType.XMP
|
||||
else -> null
|
||||
}
|
||||
}
|
|
@ -150,7 +150,7 @@ class SourceEntry {
|
|||
// finds: width, height, orientation, date, duration
|
||||
private fun fillByMetadataExtractor(context: Context) {
|
||||
// skip raw images because `metadata-extractor` reports the decoded dimensions instead of the raw dimensions
|
||||
if (!MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)
|
||||
if (!MimeTypes.canReadWithMetadataExtractor(sourceMimeType)
|
||||
|| MimeTypes.isRaw(sourceMimeType)
|
||||
) return
|
||||
|
||||
|
@ -204,7 +204,7 @@ class SourceEntry {
|
|||
|
||||
// finds: width, height, orientation, date
|
||||
private fun fillByExifInterface(context: Context) {
|
||||
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType)) return
|
||||
if (!MimeTypes.canReadWithExifInterface(sourceMimeType)) return
|
||||
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, sourceMimeType, sizeBytes)?.use { input ->
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
package deckers.thibault.aves.model.provider
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -17,41 +14,46 @@ import com.bumptech.glide.request.RequestOptions
|
|||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
import deckers.thibault.aves.model.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByPixyMeta
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import java.io.*
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
abstract class ImageProvider {
|
||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
callback.onFailure(UnsupportedOperationException())
|
||||
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
|
||||
}
|
||||
|
||||
open suspend fun delete(activity: Activity, uri: Uri, path: String?) {
|
||||
throw UnsupportedOperationException()
|
||||
throw UnsupportedOperationException("`delete` is not supported by this image provider")
|
||||
}
|
||||
|
||||
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
|
||||
callback.onFailure(UnsupportedOperationException())
|
||||
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
|
||||
}
|
||||
|
||||
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
|
||||
}
|
||||
|
||||
open fun scanObsoletePath(context: Context, path: String, mimeType: String) {
|
||||
throw UnsupportedOperationException("`scanObsoletePath` is not supported by this image provider")
|
||||
}
|
||||
|
||||
suspend fun exportMultiple(
|
||||
|
@ -123,17 +125,13 @@ abstract class ImageProvider {
|
|||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
||||
}
|
||||
val desiredFileName = desiredNameWithoutExtension + extensionFor(exportMimeType)
|
||||
|
||||
if (File(destinationDir, desiredFileName).exists()) {
|
||||
throw Exception("file with name=$desiredFileName already exists in destination directory")
|
||||
}
|
||||
val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(exportMimeType))
|
||||
|
||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||
|
||||
if (isVideo(sourceMimeType)) {
|
||||
|
@ -197,7 +195,7 @@ abstract class ImageProvider {
|
|||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
|
||||
return scanNewPath(context, destinationFullPath, exportMimeType)
|
||||
return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType)
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
@ -216,17 +214,13 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
val captureMimeType = MimeTypes.JPEG
|
||||
val desiredFileName = desiredNameWithoutExtension + extensionFor(captureMimeType)
|
||||
if (File(destinationDir, desiredFileName).exists()) {
|
||||
callback.onFailure(Exception("file with name=$desiredFileName already exists in destination directory"))
|
||||
return
|
||||
}
|
||||
val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(captureMimeType))
|
||||
|
||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, desiredNameWithoutExtension)
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||
|
||||
try {
|
||||
|
@ -295,13 +289,23 @@ abstract class ImageProvider {
|
|||
|
||||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
val newFields = scanNewPath(context, destinationFullPath, captureMimeType)
|
||||
val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType)
|
||||
callback.onSuccess(newFields)
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String {
|
||||
var nameWithoutExtension = desiredNameWithoutExtension
|
||||
var i = 0
|
||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||
i++
|
||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
||||
}
|
||||
return nameWithoutExtension
|
||||
}
|
||||
|
||||
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||
val oldFile = File(oldPath)
|
||||
val newFile = File(oldFile.parent, newFilename)
|
||||
|
@ -324,31 +328,14 @@ abstract class ImageProvider {
|
|||
return
|
||||
}
|
||||
|
||||
MediaScannerConnection.scanFile(context, arrayOf(oldPath), arrayOf(mimeType), null)
|
||||
scanObsoletePath(context, oldPath, mimeType)
|
||||
try {
|
||||
callback.onSuccess(scanNewPath(context, newFile.path, mimeType))
|
||||
callback.onSuccess(MediaStoreImageProvider().scanNewPath(context, newFile.path, mimeType))
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// support for writing EXIF
|
||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||
private fun canEditExif(mimeType: String): Boolean {
|
||||
return when (mimeType) {
|
||||
MimeTypes.DNG,
|
||||
MimeTypes.JPEG,
|
||||
MimeTypes.PNG,
|
||||
MimeTypes.WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// support for writing XMP
|
||||
private fun canEditXmp(mimeType: String): Boolean {
|
||||
return isSupportedByPixyMeta(mimeType)
|
||||
}
|
||||
|
||||
private fun editExif(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
@ -524,28 +511,14 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private fun scanPostExifEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.SIZE,
|
||||
)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return@scanFile
|
||||
}
|
||||
callback.onSuccess(newFields)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
|
||||
fun editOrientation(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
op: ExifOrientationOp,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val newFields = HashMap<String, Any?>()
|
||||
|
||||
val success = editExif(context, path, uri, mimeType, callback) { exif ->
|
||||
|
@ -568,7 +541,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
if (success) {
|
||||
scanPostExifEdit(context, path, uri, mimeType, newFields, callback)
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -662,67 +635,61 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
if (success) {
|
||||
scanPostExifEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback)
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
|
||||
suspendCoroutine { cont ->
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
|
||||
fun scanUri(uri: Uri?): FieldMap? {
|
||||
uri ?: return null
|
||||
fun removeMetadataTypes(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
types: Set<String>,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
if (!canRemoveMetadata(mimeType)) {
|
||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||
return
|
||||
}
|
||||
|
||||
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.TITLE,
|
||||
)
|
||||
val originalDocumentFile = getDocumentFile(context, path, uri)
|
||||
if (originalDocumentFile == null) {
|
||||
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
|
||||
return
|
||||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val newFields = HashMap<String, Any?>()
|
||||
newFields["uri"] = uri.toString()
|
||||
newFields["contentId"] = uri.tryParseId()
|
||||
newFields["path"] = path
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
return newFields
|
||||
outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
originalDocumentFile.openInputStream().use { input ->
|
||||
PixyMetaHelper.removeMetadata(input, output, types)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (newUri == null) {
|
||||
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
|
||||
return@scanFile
|
||||
}
|
||||
|
||||
var contentUri: Uri? = null
|
||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||
val contentId = newUri.tryParseId()
|
||||
if (contentId != null) {
|
||||
if (isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
} else if (isVideo(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
Log.d(LOG_TAG, "failed to remove metadata", e)
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
|
||||
val newFields = scanUri(contentUri) ?: scanUri(newUri)
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile)
|
||||
|
||||
if (newFields != null) {
|
||||
cont.resume(newFields)
|
||||
} else {
|
||||
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
|
||||
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
|
||||
val newFields = HashMap<String, Any?>()
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
interface ImageOpCallback {
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.app.Activity
|
|||
import android.app.RecoverableSecurityException
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
|
@ -27,6 +28,9 @@ import java.io.File
|
|||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class MediaStoreImageProvider : ImageProvider() {
|
||||
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
|
||||
|
@ -39,29 +43,41 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
var found = false
|
||||
val fetched = arrayListOf<FieldMap>()
|
||||
val id = uri.tryParseId()
|
||||
val onSuccess = fun(entry: FieldMap) {
|
||||
entry["uri"] = uri.toString()
|
||||
callback.onSuccess(entry)
|
||||
fetched.add(entry)
|
||||
}
|
||||
val alwaysValid = { _: Int, _: Int -> true }
|
||||
if (id != null) {
|
||||
if (sourceMimeType == null || isImage(sourceMimeType)) {
|
||||
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
|
||||
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)) return
|
||||
found = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION)
|
||||
}
|
||||
if (sourceMimeType == null || isVideo(sourceMimeType)) {
|
||||
if (!found && (sourceMimeType == null || isVideo(sourceMimeType))) {
|
||||
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
|
||||
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)) return
|
||||
found = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// 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 = sourceMimeType)) return
|
||||
found = fetchFrom(context, alwaysValid, onSuccess, uri, BASE_PROJECTION, fileMimeType = sourceMimeType)
|
||||
}
|
||||
|
||||
if (found && fetched.isNotEmpty()) {
|
||||
if (fetched.size == 1) {
|
||||
callback.onSuccess(fetched.first())
|
||||
} else {
|
||||
callback.onFailure(Exception("found ${fetched.size} entries at uri=$uri"))
|
||||
}
|
||||
} else {
|
||||
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
|
||||
}
|
||||
}
|
||||
|
||||
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
|
||||
val foundContentIds = ArrayList<Int>()
|
||||
|
@ -82,7 +98,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
check(context, IMAGE_CONTENT_URI)
|
||||
check(context, VIDEO_CONTENT_URI)
|
||||
return knownContentIds.filter { id: Int -> !foundContentIds.contains(id) }.toList()
|
||||
return knownContentIds.subtract(foundContentIds).toList()
|
||||
}
|
||||
|
||||
fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> {
|
||||
|
@ -362,6 +378,90 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.SIZE,
|
||||
)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) newFields["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return@scanFile
|
||||
}
|
||||
callback.onSuccess(newFields)
|
||||
}
|
||||
}
|
||||
|
||||
override fun scanObsoletePath(context: Context, path: String, mimeType: String) {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType), null)
|
||||
}
|
||||
|
||||
suspend fun scanNewPath(context: Context, path: String, mimeType: String): FieldMap =
|
||||
suspendCoroutine { cont ->
|
||||
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
|
||||
fun scanUri(uri: Uri?): FieldMap? {
|
||||
uri ?: return null
|
||||
|
||||
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
|
||||
val projection = arrayOf(
|
||||
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
MediaStore.MediaColumns.TITLE,
|
||||
)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val newFields = HashMap<String, Any?>()
|
||||
newFields["uri"] = uri.toString()
|
||||
newFields["contentId"] = uri.tryParseId()
|
||||
newFields["path"] = path
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
return newFields
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to scan uri=$uri", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (newUri == null) {
|
||||
cont.resumeWithException(Exception("failed to get URI of item at path=$path"))
|
||||
return@scanFile
|
||||
}
|
||||
|
||||
var contentUri: Uri? = null
|
||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
||||
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||
val contentId = newUri.tryParseId()
|
||||
if (contentId != null) {
|
||||
if (isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
} else if (isVideo(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
}
|
||||
}
|
||||
|
||||
// prefer image/video content URI, fallback to original URI (possibly a file content URI)
|
||||
val newFields = scanUri(contentUri) ?: scanUri(newUri)
|
||||
|
||||
if (newFields != null) {
|
||||
cont.resume(newFields)
|
||||
} else {
|
||||
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ object MimeTypes {
|
|||
// raw raster
|
||||
private const val ARW = "image/x-sony-arw"
|
||||
private const val CR2 = "image/x-canon-cr2"
|
||||
const val DNG = "image/x-adobe-dng"
|
||||
private const val DNG = "image/x-adobe-dng"
|
||||
private const val NEF = "image/x-nikon-nef"
|
||||
private const val NRW = "image/x-nikon-nrw"
|
||||
private const val ORF = "image/x-olympus-orf"
|
||||
|
@ -65,27 +65,46 @@ object MimeTypes {
|
|||
}
|
||||
|
||||
// as of Flutter v1.22.0, with additional custom handling for SVG
|
||||
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||
fun canDecodeWithFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of `metadata-extractor` v2.14.0
|
||||
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
fun canReadWithMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
DJVU, WBMP, MKV, MP2T, MP2TS, OGV, WEBM -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
// as of `ExifInterface` v1.3.1, `isSupportedMimeType` reports
|
||||
// no support for TIFF images, but it can actually open them (maybe other formats too)
|
||||
fun isSupportedByExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
|
||||
fun canReadWithExifInterface(mimeType: String, strict: Boolean = true) = ExifInterface.isSupportedMimeType(mimeType) || !strict
|
||||
|
||||
fun isSupportedByPixyMeta(mimeType: String) = when (mimeType) {
|
||||
// as of latest PixyMeta
|
||||
fun canReadWithPixyMeta(mimeType: String) = when (mimeType) {
|
||||
JPEG, TIFF, PNG, GIF, BMP -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||
fun canEditExif(mimeType: String) = when (mimeType) {
|
||||
DNG,
|
||||
JPEG,
|
||||
PNG,
|
||||
WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||
JPEG, TIFF -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// Glide automatically applies EXIF orientation when decoding images of known formats
|
||||
// but we need to rotate the decoded bitmap for the other formats
|
||||
// maybe related to ExifInterface version used by Glide:
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.30'
|
||||
ext.kotlin_version = '1.5.31'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.0.1'
|
||||
classpath 'com.android.tools.build:gradle:7.0.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
import 'dart:math';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -33,7 +33,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await imageFileService.getRegion(
|
||||
final bytes = await mediaFileService.getRegion(
|
||||
uri,
|
||||
mimeType,
|
||||
key.rotationDegrees,
|
||||
|
@ -56,11 +56,11 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, RegionProviderKey key, ImageErrorListener handleError) {
|
||||
imageFileService.resumeLoading(key);
|
||||
mediaFileService.resumeLoading(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
void pause() => imageFileService.cancelRegion(key);
|
||||
void pause() => mediaFileService.cancelRegion(key);
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -35,7 +35,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
final mimeType = key.mimeType;
|
||||
final pageId = key.pageId;
|
||||
try {
|
||||
final bytes = await imageFileService.getThumbnail(
|
||||
final bytes = await mediaFileService.getThumbnail(
|
||||
uri: uri,
|
||||
mimeType: mimeType,
|
||||
pageId: pageId,
|
||||
|
@ -57,11 +57,11 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
|
||||
@override
|
||||
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, ThumbnailProviderKey key, ImageErrorListener handleError) {
|
||||
imageFileService.resumeLoading(key);
|
||||
mediaFileService.resumeLoading(key);
|
||||
super.resolveStreamForKey(configuration, stream, key, handleError);
|
||||
}
|
||||
|
||||
void pause() => imageFileService.cancelThumbnail(key);
|
||||
void pause() => mediaFileService.cancelThumbnail(key);
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -50,7 +49,7 @@ class UriImage extends ImageProvider<UriImage> with EquatableMixin {
|
|||
assert(key == this);
|
||||
|
||||
try {
|
||||
final bytes = await imageFileService.getImage(
|
||||
final bytes = await mediaFileService.getImage(
|
||||
uri,
|
||||
mimeType,
|
||||
rotationDegrees,
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"@appName": {},
|
||||
"welcomeMessage": "Welcome to Aves",
|
||||
"@welcomeMessage": {},
|
||||
"welcomeCrashReportToggle": "Allow anonymous crash reporting (optional)",
|
||||
"welcomeCrashReportToggle": "Allow anonymous error reporting (optional)",
|
||||
"@welcomeCrashReportToggle": {},
|
||||
"welcomeTermsToggle": "I agree to the terms and conditions",
|
||||
"@welcomeTermsToggle": {},
|
||||
|
@ -14,6 +14,19 @@
|
|||
}
|
||||
},
|
||||
|
||||
"timeSeconds": "{seconds, plural, =1{1 second} other{{seconds} seconds}}",
|
||||
"@timeSeconds": {
|
||||
"placeholders": {
|
||||
"seconds": {}
|
||||
}
|
||||
},
|
||||
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
|
||||
"@timeMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {}
|
||||
}
|
||||
},
|
||||
|
||||
"applyButtonLabel": "APPLY",
|
||||
"@applyButtonLabel": {},
|
||||
"deleteButtonLabel": "DELETE",
|
||||
|
@ -103,7 +116,7 @@
|
|||
"@entryActionOpen": {},
|
||||
"entryActionSetAs": "Set as…",
|
||||
"@entryActionSetAs": {},
|
||||
"entryActionOpenMap": "Show on map…",
|
||||
"entryActionOpenMap": "Show in map app…",
|
||||
"@entryActionOpenMap": {},
|
||||
"entryActionRotateScreen": "Rotate screen",
|
||||
"@entryActionRotateScreen": {},
|
||||
|
@ -131,6 +144,8 @@
|
|||
|
||||
"entryInfoActionEditDate": "Edit date & time",
|
||||
"@entryInfoActionEditDate": {},
|
||||
"entryInfoActionRemoveMetadata": "Remove metadata",
|
||||
"@entryInfoActionRemoveMetadata": {},
|
||||
|
||||
"filterFavouriteLabel": "Favourite",
|
||||
"@filterFavouriteLabel": {},
|
||||
|
@ -185,6 +200,11 @@
|
|||
"keepScreenOnAlways": "Always",
|
||||
"@keepScreenOnAlways": {},
|
||||
|
||||
"accessibilityAnimationsRemove": "Prevent screen effects",
|
||||
"@accessibilityAnimationsRemove": {},
|
||||
"accessibilityAnimationsKeep": "Keep screen effects",
|
||||
"@accessibilityAnimationsKeep": {},
|
||||
|
||||
"albumTierNew": "New",
|
||||
"@albumTierNew": {},
|
||||
"albumTierPinned": "Pinned",
|
||||
|
@ -325,6 +345,14 @@
|
|||
"editEntryDateDialogMinutes": "Minutes",
|
||||
"@editEntryDateDialogMinutes": {},
|
||||
|
||||
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
||||
"@removeEntryMetadataDialogTitle": {},
|
||||
"removeEntryMetadataDialogMore": "More",
|
||||
"@removeEntryMetadataDialogMore": {},
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside this motion photo. Are you sure you want to remove it?",
|
||||
"@removeEntryMetadataMotionPhotoXmpWarningDialogMessage": {},
|
||||
|
||||
"videoSpeedDialogLabel": "Playback speed",
|
||||
"@videoSpeedDialogLabel": {},
|
||||
|
||||
|
@ -415,7 +443,7 @@
|
|||
"@aboutLicensesFlutterPackages": {},
|
||||
"aboutLicensesDartPackages": "Dart Packages",
|
||||
"@aboutLicensesDartPackages": {},
|
||||
"aboutLicensesShowAllButtonLabel": "SHOW ALL LICENSES",
|
||||
"aboutLicensesShowAllButtonLabel": "Show All Licenses",
|
||||
"@aboutLicensesShowAllButtonLabel": {},
|
||||
|
||||
"collectionPageTitle": "Collection",
|
||||
|
@ -435,8 +463,8 @@
|
|||
"@collectionActionCopy": {},
|
||||
"collectionActionMove": "Move to album",
|
||||
"@collectionActionMove": {},
|
||||
"collectionActionRefreshMetadata": "Refresh metadata",
|
||||
"@collectionActionRefreshMetadata": {},
|
||||
"collectionActionRescan": "Rescan",
|
||||
"@collectionActionRescan": {},
|
||||
|
||||
"collectionSortTitle": "Sort",
|
||||
"@collectionSortTitle": {},
|
||||
|
@ -606,6 +634,8 @@
|
|||
"@settingsPageTitle": {},
|
||||
"settingsSystemDefault": "System",
|
||||
"@settingsSystemDefault": {},
|
||||
"settingsDefault": "Default",
|
||||
"@settingsDefault": {},
|
||||
|
||||
"settingsActionExport": "Export",
|
||||
"@settingsActionExport": {},
|
||||
|
@ -642,6 +672,8 @@
|
|||
"@settingsSectionThumbnails": {},
|
||||
"settingsThumbnailShowLocationIcon": "Show location icon",
|
||||
"@settingsThumbnailShowLocationIcon": {},
|
||||
"settingsThumbnailShowMotionPhotoIcon": "Show motion photo icon",
|
||||
"@settingsThumbnailShowMotionPhotoIcon": {},
|
||||
"settingsThumbnailShowRawIcon": "Show raw icon",
|
||||
"@settingsThumbnailShowRawIcon": {},
|
||||
"settingsThumbnailShowVideoDuration": "Show video duration",
|
||||
|
@ -734,8 +766,8 @@
|
|||
|
||||
"settingsSectionPrivacy": "Privacy",
|
||||
"@settingsSectionPrivacy": {},
|
||||
"settingsEnableCrashReport": "Allow anonymous error reporting",
|
||||
"@settingsEnableCrashReport": {},
|
||||
"settingsEnableErrorReporting": "Allow anonymous error reporting",
|
||||
"@settingsEnableErrorReporting": {},
|
||||
"settingsSaveSearchHistory": "Save search history",
|
||||
"@settingsSaveSearchHistory": {},
|
||||
|
||||
|
@ -770,6 +802,17 @@
|
|||
"settingsStorageAccessRevokeTooltip": "Revoke",
|
||||
"@settingsStorageAccessRevokeTooltip": {},
|
||||
|
||||
"settingsSectionAccessibility": "Accessibility",
|
||||
"@settingsSectionAccessibility": {},
|
||||
"settingsRemoveAnimationsTile": "Remove animations",
|
||||
"@settingsRemoveAnimationsTile": {},
|
||||
"settingsRemoveAnimationsTitle": "Remove Animations",
|
||||
"@settingsRemoveAnimationsTitle": {},
|
||||
"settingsTimeToTakeActionTile": "Time to take action",
|
||||
"@settingsTimeToTakeActionTile": {},
|
||||
"settingsTimeToTakeActionTitle": "Time to Take Action",
|
||||
"@settingsTimeToTakeActionTitle": {},
|
||||
|
||||
"settingsSectionLanguage": "Language & Formats",
|
||||
"@settingsSectionLanguage": {},
|
||||
"settingsLanguage": "Language",
|
||||
|
@ -779,9 +822,6 @@
|
|||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||
"@settingsCoordinateFormatTitle": {},
|
||||
|
||||
"mapPageTitle": "Map",
|
||||
"@mapPageTitle": {},
|
||||
|
||||
"statsPageTitle": "Stats",
|
||||
"@statsPageTitle": {},
|
||||
"statsImage": "{count, plural, =1{image} other{images}}",
|
||||
|
@ -846,18 +886,24 @@
|
|||
"viewerInfoLabelAddress": "Address",
|
||||
"@viewerInfoLabelAddress": {},
|
||||
|
||||
"viewerInfoMapStyleTitle": "Map Style",
|
||||
"@viewerInfoMapStyleTitle": {},
|
||||
"viewerInfoMapStyleTooltip": "Select map style",
|
||||
"@viewerInfoMapStyleTooltip": {},
|
||||
"viewerInfoMapZoomInTooltip": "Zoom in",
|
||||
"@viewerInfoMapZoomInTooltip": {},
|
||||
"viewerInfoMapZoomOutTooltip": "Zoom out",
|
||||
"@viewerInfoMapZoomOutTooltip": {},
|
||||
"mapStyleTitle": "Map Style",
|
||||
"@mapStyleTitle": {},
|
||||
"mapStyleTooltip": "Select map style",
|
||||
"@mapStyleTooltip": {},
|
||||
"mapZoomInTooltip": "Zoom in",
|
||||
"@mapZoomInTooltip": {},
|
||||
"mapZoomOutTooltip": "Zoom out",
|
||||
"@mapZoomOutTooltip": {},
|
||||
"mapPointNorthUpTooltip": "Point north up",
|
||||
"@mapPointNorthUpTooltip": {},
|
||||
"mapAttributionOsmHot": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [HOT](https://www.hotosm.org/) • Hosted by [OSM France](https://openstreetmap.fr/)",
|
||||
"@mapAttributionOsmHot": {},
|
||||
"mapAttributionStamen": "Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors • Tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"@mapAttributionStamen": {},
|
||||
"openMapPageTooltip": "View on Map page",
|
||||
"@openMapPageTooltip": {},
|
||||
"mapEmptyRegion": "No images in this region",
|
||||
"@mapEmpty": {},
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "Failed to extract embedded data",
|
||||
"@viewerInfoOpenEmbeddedFailureFeedback": {},
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
"welcomeTermsToggle": "이용약관에 동의합니다",
|
||||
"itemCount": "{count, plural, other{{count}개}}",
|
||||
|
||||
"timeSeconds": "{seconds, plural, other{{seconds}초}}",
|
||||
"timeMinutes": "{minutes, plural, other{{minutes}분}}",
|
||||
|
||||
"applyButtonLabel": "확인",
|
||||
"deleteButtonLabel": "삭제",
|
||||
"nextButtonLabel": "다음",
|
||||
|
@ -52,7 +55,7 @@
|
|||
"entryActionEdit": "편집…",
|
||||
"entryActionOpen": "다른 앱에서 열기…",
|
||||
"entryActionSetAs": "다음 용도로 사용…",
|
||||
"entryActionOpenMap": "지도에서 보기…",
|
||||
"entryActionOpenMap": "지도 앱에서 보기…",
|
||||
"entryActionRotateScreen": "화면 회전",
|
||||
"entryActionAddFavourite": "즐겨찾기에 추가",
|
||||
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
|
||||
|
@ -67,6 +70,7 @@
|
|||
"videoActionSettings": "설정",
|
||||
|
||||
"entryInfoActionEditDate": "날짜와 시간 수정",
|
||||
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
|
||||
|
||||
"filterFavouriteLabel": "즐겨찾기",
|
||||
"filterLocationEmptyLabel": "장소 없음",
|
||||
|
@ -97,6 +101,9 @@
|
|||
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
|
||||
"keepScreenOnAlways": "항상 켜짐",
|
||||
|
||||
"accessibilityAnimationsRemove": "화면 효과 제한",
|
||||
"accessibilityAnimationsKeep": "화면 효과 유지",
|
||||
|
||||
"albumTierNew": "신규",
|
||||
"albumTierPinned": "고정",
|
||||
"albumTierSpecial": "기본",
|
||||
|
@ -149,6 +156,11 @@
|
|||
"editEntryDateDialogHours": "시간",
|
||||
"editEntryDateDialogMinutes": "분",
|
||||
|
||||
"removeEntryMetadataDialogTitle": "메타데이터 삭제",
|
||||
"removeEntryMetadataDialogMore": "더 보기",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP가 있어야 모션 포토에 포함되는 동영상을 재생할 수 있습니다. 삭제하시겠습니까?",
|
||||
|
||||
"videoSpeedDialogLabel": "재생 배속",
|
||||
|
||||
"videoStreamSelectionDialogVideo": "동영상",
|
||||
|
@ -207,7 +219,7 @@
|
|||
"collectionActionAddShortcut": "홈 화면에 추가",
|
||||
"collectionActionCopy": "앨범으로 복사",
|
||||
"collectionActionMove": "앨범으로 이동",
|
||||
"collectionActionRefreshMetadata": "새로 분석",
|
||||
"collectionActionRescan": "새로 분석",
|
||||
|
||||
"collectionSortTitle": "정렬",
|
||||
"collectionSortDate": "날짜",
|
||||
|
@ -288,6 +300,7 @@
|
|||
|
||||
"settingsPageTitle": "설정",
|
||||
"settingsSystemDefault": "시스템",
|
||||
"settingsDefault": "기본",
|
||||
|
||||
"settingsActionExport": "내보내기",
|
||||
"settingsActionImport": "가져오기",
|
||||
|
@ -308,6 +321,7 @@
|
|||
|
||||
"settingsSectionThumbnails": "섬네일",
|
||||
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
|
||||
"settingsThumbnailShowMotionPhotoIcon": "모션 포토 아이콘 표시",
|
||||
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
|
||||
"settingsThumbnailShowVideoDuration": "동영상 길이 표시",
|
||||
|
||||
|
@ -357,7 +371,7 @@
|
|||
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
|
||||
|
||||
"settingsSectionPrivacy": "개인정보 보호",
|
||||
"settingsEnableCrashReport": "오류 보고서 보내기",
|
||||
"settingsEnableErrorReporting": "오류 보고서 보내기",
|
||||
"settingsSaveSearchHistory": "검색기록",
|
||||
|
||||
"settingsHiddenFiltersTile": "숨겨진 필터",
|
||||
|
@ -377,13 +391,17 @@
|
|||
"settingsStorageAccessEmpty": "접근 허용이 없습니다",
|
||||
"settingsStorageAccessRevokeTooltip": "취소",
|
||||
|
||||
"settingsSectionAccessibility": "접근성",
|
||||
"settingsRemoveAnimationsTile": "애니메이션 삭제",
|
||||
"settingsRemoveAnimationsTitle": "애니메이션 삭제",
|
||||
"settingsTimeToTakeActionTile": "액션 취하기 전 대기 시간",
|
||||
"settingsTimeToTakeActionTitle": "액션 취하기 전 대기 시간",
|
||||
|
||||
"settingsSectionLanguage": "언어 및 표시 형식",
|
||||
"settingsLanguage": "언어",
|
||||
"settingsCoordinateFormatTile": "좌표 표현",
|
||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||
|
||||
"mapPageTitle": "지도",
|
||||
|
||||
"statsPageTitle": "통계",
|
||||
"statsImage": "{count, plural, other{사진}}",
|
||||
"statsVideo": "{count, plural, other{동영상}}",
|
||||
|
@ -412,12 +430,15 @@
|
|||
"viewerInfoLabelCoordinates": "좌표",
|
||||
"viewerInfoLabelAddress": "주소",
|
||||
|
||||
"viewerInfoMapStyleTitle": "지도 유형",
|
||||
"viewerInfoMapStyleTooltip": "지도 유형 선택",
|
||||
"viewerInfoMapZoomInTooltip": "확대",
|
||||
"viewerInfoMapZoomOutTooltip": "축소",
|
||||
"mapStyleTitle": "지도 유형",
|
||||
"mapStyleTooltip": "지도 유형 선택",
|
||||
"mapZoomInTooltip": "확대",
|
||||
"mapZoomOutTooltip": "축소",
|
||||
"mapPointNorthUpTooltip": "북쪽을 위로 가리키기",
|
||||
"mapAttributionOsmHot": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [HOT](https://www.hotosm.org/) • 호스팅 [OSM France](https://openstreetmap.fr/)",
|
||||
"mapAttributionStamen": "지도 데이터 © [OpenStreetMap](https://www.openstreetmap.org/copyright) 기여자 • 타일 [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
|
||||
"openMapPageTooltip": "지도 페이지에서 보기",
|
||||
"mapEmptyRegion": "이 지역의 사진이 없습니다",
|
||||
|
||||
"viewerInfoOpenEmbeddedFailureFeedback": "첨부 데이터 추출 오류",
|
||||
"viewerInfoOpenLinkText": "열기",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:isolate';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
enum EntryInfoAction {
|
||||
editDate,
|
||||
removeMetadata,
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ enum EntrySetAction {
|
|||
delete,
|
||||
copy,
|
||||
move,
|
||||
refreshMetadata,
|
||||
rescan,
|
||||
}
|
||||
|
||||
class EntrySetActions {
|
||||
|
@ -28,7 +28,7 @@ class EntrySetActions {
|
|||
EntrySetAction.delete,
|
||||
EntrySetAction.copy,
|
||||
EntrySetAction.move,
|
||||
EntrySetAction.refreshMetadata,
|
||||
EntrySetAction.rescan,
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
];
|
||||
|
@ -65,8 +65,8 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return context.l10n.collectionActionCopy;
|
||||
case EntrySetAction.move:
|
||||
return context.l10n.collectionActionMove;
|
||||
case EntrySetAction.refreshMetadata:
|
||||
return context.l10n.collectionActionRefreshMetadata;
|
||||
case EntrySetAction.rescan:
|
||||
return context.l10n.collectionActionRescan;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,7 @@ extension ExtraEntrySetAction on EntrySetAction {
|
|||
return AIcons.copy;
|
||||
case EntrySetAction.move:
|
||||
return AIcons.move;
|
||||
case EntrySetAction.refreshMetadata:
|
||||
case EntrySetAction.rescan:
|
||||
return AIcons.refresh;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
|
|
@ -6,14 +6,15 @@ import 'package:aves/model/favourites.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/video/metadata.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/service_policy.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/geocoding_service.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -35,7 +36,7 @@ class AvesEntry {
|
|||
|
||||
// `dateModifiedSecs` can be missing in viewer mode
|
||||
int? _dateModifiedSecs;
|
||||
final int? sourceDateTakenMillis;
|
||||
int? sourceDateTakenMillis;
|
||||
int? _durationMillis;
|
||||
int? _catalogDateMillis;
|
||||
CatalogMetadata? _catalogMetadata;
|
||||
|
@ -230,7 +231,6 @@ class AvesEntry {
|
|||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// support for writing EXIF
|
||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||
bool get canEditExif {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
|
@ -244,6 +244,17 @@ class AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canRemoveMetadata {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||
|
@ -339,11 +350,13 @@ class AvesEntry {
|
|||
_bestDate = null;
|
||||
}
|
||||
|
||||
// TODO TLAD cache _monthTaken
|
||||
DateTime? get monthTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month);
|
||||
}
|
||||
|
||||
// TODO TLAD cache _dayTaken
|
||||
DateTime? get dayTaken {
|
||||
final d = bestDate;
|
||||
return d == null ? null : DateTime(d.year, d.month, d.day);
|
||||
|
@ -434,7 +447,7 @@ class AvesEntry {
|
|||
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
||||
await _applyNewFields(fields, persist: persist);
|
||||
}
|
||||
catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
|
||||
catalogMetadata = await metadataFetchService.getCatalogMetadata(this, background: background);
|
||||
|
||||
if (isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) {
|
||||
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
|
||||
|
@ -551,8 +564,13 @@ class AvesEntry {
|
|||
if (path is String) this.path = path;
|
||||
final contentId = newFields['contentId'];
|
||||
if (contentId is int) this.contentId = contentId;
|
||||
|
||||
final sourceTitle = newFields['title'];
|
||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
|
||||
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
|
||||
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
|
||||
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
|
||||
|
||||
final width = newFields['width'];
|
||||
if (width is int) this.width = width;
|
||||
|
@ -578,8 +596,26 @@ class AvesEntry {
|
|||
metadataChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refresh({required bool persist}) async {
|
||||
_catalogMetadata = null;
|
||||
_addressDetails = null;
|
||||
_bestDate = null;
|
||||
_bestTitle = null;
|
||||
_xmpSubjects = null;
|
||||
if (persist) {
|
||||
await metadataDb.removeIds({contentId!}, metadataOnly: true);
|
||||
}
|
||||
|
||||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updated != null) {
|
||||
await _applyNewFields(updated.toMap(), persist: persist);
|
||||
await catalog(background: false, persist: persist);
|
||||
await locate(background: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> rotate({required bool clockwise, required bool persist}) async {
|
||||
final newFields = await imageFileService.rotate(this, clockwise: clockwise);
|
||||
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -591,7 +627,7 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
Future<bool> flip({required bool persist}) async {
|
||||
final newFields = await imageFileService.flip(this);
|
||||
final newFields = await metadataEditService.flip(this);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
|
@ -602,18 +638,19 @@ class AvesEntry {
|
|||
return true;
|
||||
}
|
||||
|
||||
Future<bool> editDate(DateModifier modifier, {required bool persist}) async {
|
||||
final newFields = await imageFileService.editDate(this, modifier);
|
||||
if (newFields.isEmpty) return false;
|
||||
Future<bool> editDate(DateModifier modifier) async {
|
||||
final newFields = await metadataEditService.editDate(this, modifier);
|
||||
return newFields.isNotEmpty;
|
||||
}
|
||||
|
||||
await _applyNewFields(newFields, persist: persist);
|
||||
await catalog(background: false, persist: persist, force: true);
|
||||
return true;
|
||||
Future<bool> removeMetadata(Set<MetadataType> types) async {
|
||||
final newFields = await metadataEditService.removeTypes(this, types);
|
||||
return newFields.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<bool> delete() {
|
||||
final completer = Completer<bool>();
|
||||
imageFileService.delete([this]).listen(
|
||||
mediaFileService.delete([this]).listen(
|
||||
(event) => completer.complete(event.success),
|
||||
onError: completer.completeError,
|
||||
onDone: () {
|
||||
|
@ -694,7 +731,7 @@ class AvesEntry {
|
|||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return await metadataService.getMultiPageInfo(this);
|
||||
return await metadataFetchService.getMultiPageInfo(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
|
|
|
@ -19,6 +19,11 @@ class QueryFilter extends CollectionFilter {
|
|||
|
||||
QueryFilter(this.query, {this.colorful = true}) {
|
||||
var upQuery = query.toUpperCase();
|
||||
if (upQuery.startsWith('ID=')) {
|
||||
final id = int.tryParse(upQuery.substring(3));
|
||||
_test = (entry) => entry.contentId == id;
|
||||
return;
|
||||
}
|
||||
|
||||
// allow NOT queries starting with `-`
|
||||
final not = upQuery.startsWith('-');
|
||||
|
|
|
@ -10,3 +10,72 @@ enum DateEditAction {
|
|||
shift,
|
||||
clear,
|
||||
}
|
||||
|
||||
enum MetadataType {
|
||||
// Exif: https://en.wikipedia.org/wiki/Exif
|
||||
exif,
|
||||
// ICC profile: https://en.wikipedia.org/wiki/ICC_profile
|
||||
iccProfile,
|
||||
// IPTC: https://en.wikipedia.org/wiki/IPTC_Information_Interchange_Model
|
||||
iptc,
|
||||
// JPEG APP0 / JFIF: https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format
|
||||
jfif,
|
||||
// JPEG APP14 / Adobe: https://www.exiftool.org/TagNames/JPEG.html#Adobe
|
||||
jpegAdobe,
|
||||
// JPEG COM marker
|
||||
jpegComment,
|
||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||
jpegDucky,
|
||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
photoshopIrb,
|
||||
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
|
||||
xmp,
|
||||
}
|
||||
|
||||
class MetadataTypes {
|
||||
static const main = {
|
||||
MetadataType.exif,
|
||||
MetadataType.xmp,
|
||||
};
|
||||
|
||||
static const common = {
|
||||
MetadataType.exif,
|
||||
MetadataType.xmp,
|
||||
MetadataType.iccProfile,
|
||||
MetadataType.iptc,
|
||||
MetadataType.photoshopIrb,
|
||||
};
|
||||
|
||||
static const jpeg = {
|
||||
MetadataType.jfif,
|
||||
MetadataType.jpegAdobe,
|
||||
MetadataType.jpegComment,
|
||||
MetadataType.jpegDucky,
|
||||
};
|
||||
}
|
||||
|
||||
extension ExtraMetadataType on MetadataType {
|
||||
// match `ExifInterface` directory names
|
||||
String getText() {
|
||||
switch (this) {
|
||||
case MetadataType.exif:
|
||||
return 'Exif';
|
||||
case MetadataType.iccProfile:
|
||||
return 'ICC Profile';
|
||||
case MetadataType.iptc:
|
||||
return 'IPTC';
|
||||
case MetadataType.jfif:
|
||||
return 'JFIF';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'Adobe JPEG';
|
||||
case MetadataType.jpegComment:
|
||||
return 'JpegComment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'Ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
return 'Photoshop';
|
||||
case MetadataType.xmp:
|
||||
return 'XMP';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
@ -171,7 +171,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
|
@ -188,7 +188,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
||||
// debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
||||
}
|
||||
|
||||
// entries
|
||||
|
@ -202,11 +202,11 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||
debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
// debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
|
31
lib/model/settings/accessibility_animations.dart
Normal file
31
lib/model/settings/accessibility_animations.dart
Normal file
|
@ -0,0 +1,31 @@
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
extension ExtraAccessibilityAnimations on AccessibilityAnimations {
|
||||
String getName(BuildContext context) {
|
||||
switch (this) {
|
||||
case AccessibilityAnimations.system:
|
||||
return context.l10n.settingsSystemDefault;
|
||||
case AccessibilityAnimations.disabled:
|
||||
return context.l10n.accessibilityAnimationsRemove;
|
||||
case AccessibilityAnimations.enabled:
|
||||
return context.l10n.accessibilityAnimationsKeep;
|
||||
}
|
||||
}
|
||||
|
||||
bool get animate {
|
||||
switch (this) {
|
||||
case AccessibilityAnimations.system:
|
||||
// as of Flutter v2.5.1, the check for `disableAnimations` is unreliable
|
||||
// so we cannot use `window.accessibilityFeatures.disableAnimations` nor `MediaQuery.of(context).disableAnimations`
|
||||
return !settings.areAnimationsRemoved;
|
||||
case AccessibilityAnimations.disabled:
|
||||
return false;
|
||||
case AccessibilityAnimations.enabled:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
23
lib/model/settings/accessibility_timeout.dart
Normal file
23
lib/model/settings/accessibility_timeout.dart
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'enums.dart';
|
||||
|
||||
extension ExtraAccessibilityTimeout on AccessibilityTimeout {
|
||||
String getName(BuildContext context) {
|
||||
switch (this) {
|
||||
case AccessibilityTimeout.system:
|
||||
return context.l10n.settingsSystemDefault;
|
||||
case AccessibilityTimeout.appDefault:
|
||||
return context.l10n.settingsDefault;
|
||||
case AccessibilityTimeout.s10:
|
||||
return context.l10n.timeSeconds(10);
|
||||
case AccessibilityTimeout.s30:
|
||||
return context.l10n.timeSeconds(30);
|
||||
case AccessibilityTimeout.s60:
|
||||
return context.l10n.timeMinutes(1);
|
||||
case AccessibilityTimeout.s120:
|
||||
return context.l10n.timeMinutes(2);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,8 +12,6 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
|||
return context.l10n.coordinateFormatDms;
|
||||
case CoordinateFormat.decimal:
|
||||
return context.l10n.coordinateFormatDecimal;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,8 +21,6 @@ extension ExtraCoordinateFormat on CoordinateFormat {
|
|||
return toDMS(latLng).join(', ');
|
||||
case CoordinateFormat.decimal:
|
||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import 'package:flutter/material.dart';
|
|||
class SettingsDefaults {
|
||||
// app
|
||||
static const hasAcceptedTerms = false;
|
||||
static const isCrashlyticsEnabled = false;
|
||||
static const isErrorReportingEnabled = false;
|
||||
static const mustBackTwiceToExit = true;
|
||||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
static const homePage = HomePageSetting.collection;
|
||||
|
@ -38,6 +38,7 @@ class SettingsDefaults {
|
|||
EntrySetAction.delete,
|
||||
];
|
||||
static const showThumbnailLocation = true;
|
||||
static const showThumbnailMotionPhoto = true;
|
||||
static const showThumbnailRaw = true;
|
||||
static const showThumbnailVideoDuration = true;
|
||||
|
||||
|
@ -56,7 +57,7 @@ class SettingsDefaults {
|
|||
static const showOverlayMinimap = false;
|
||||
static const showOverlayInfo = true;
|
||||
static const showOverlayShootingDetails = false;
|
||||
static const enableOverlayBlurEffect = true;
|
||||
static const enableOverlayBlurEffect = true; // `enableOverlayBlurEffect` has a contextual default value
|
||||
static const viewerUseCutout = true;
|
||||
|
||||
// video
|
||||
|
@ -77,7 +78,7 @@ class SettingsDefaults {
|
|||
static const subtitleBackgroundColor = Colors.transparent;
|
||||
|
||||
// info
|
||||
static const infoMapStyle = EntryMapStyle.stamenWatercolor;
|
||||
static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
|
||||
static const infoMapZoom = 12.0;
|
||||
static const coordinateFormat = CoordinateFormat.dms;
|
||||
|
||||
|
@ -86,4 +87,8 @@ class SettingsDefaults {
|
|||
|
||||
// search
|
||||
static const saveSearchHistory = true;
|
||||
|
||||
// accessibility
|
||||
static const accessibilityAnimations = AccessibilityAnimations.system;
|
||||
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
enum CoordinateFormat { dms, decimal }
|
||||
|
||||
enum AccessibilityAnimations { system, disabled, enabled }
|
||||
|
||||
enum AccessibilityTimeout { system, appDefault, s10, s30, s60, s120 }
|
||||
|
||||
enum EntryBackground { black, white, checkered }
|
||||
|
||||
enum HomePageSetting { collection, albums }
|
||||
|
|
|
@ -12,8 +12,6 @@ extension ExtraHomePageSetting on HomePageSetting {
|
|||
return context.l10n.collectionPageTitle;
|
||||
case HomePageSetting.albums:
|
||||
return context.l10n.albumPageTitle;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,8 +21,6 @@ extension ExtraHomePageSetting on HomePageSetting {
|
|||
return CollectionPage.routeName;
|
||||
case HomePageSetting.albums:
|
||||
return AlbumListPage.routeName;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,8 +18,6 @@ extension ExtraEntryMapStyle on EntryMapStyle {
|
|||
return context.l10n.mapStyleStamenToner;
|
||||
case EntryMapStyle.stamenWatercolor:
|
||||
return context.l10n.mapStyleStamenWatercolor;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -13,8 +13,6 @@ extension ExtraKeepScreenOn on KeepScreenOn {
|
|||
return context.l10n.keepScreenOnViewerOnly;
|
||||
case KeepScreenOn.always:
|
||||
return context.l10n.keepScreenOnAlways;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
|
@ -8,13 +9,10 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/settings/defaults.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/device_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/services/accessibility_service.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
@ -22,7 +20,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
final Settings settings = Settings._private();
|
||||
|
||||
class Settings extends ChangeNotifier {
|
||||
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settingschange');
|
||||
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
|
||||
final StreamController<String> _updateStreamController = StreamController<String>.broadcast();
|
||||
|
||||
Stream<String> get updateStream => _updateStreamController.stream;
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
|
@ -40,7 +41,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// app
|
||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const isCrashlyticsEnabledKey = 'is_crashlytics_enabled';
|
||||
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
|
@ -58,6 +59,7 @@ class Settings extends ChangeNotifier {
|
|||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
static const collectionSelectionQuickActionsKey = 'collection_selection_quick_actions';
|
||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||
static const showThumbnailMotionPhotoKey = 'show_thumbnail_motion_photo';
|
||||
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
||||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||
|
||||
|
@ -103,32 +105,42 @@ class Settings extends ChangeNotifier {
|
|||
static const saveSearchHistoryKey = 'save_search_history';
|
||||
static const searchHistoryKey = 'search_history';
|
||||
|
||||
// accessibility
|
||||
static const accessibilityAnimationsKey = 'accessibility_animations';
|
||||
static const timeToTakeActionKey = 'time_to_take_action';
|
||||
|
||||
// version
|
||||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isRotationLocked = await windowService.isRotationLocked();
|
||||
}
|
||||
// platform settings
|
||||
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
|
||||
static const platformAccelerometerRotationKey = 'accelerometer_rotation';
|
||||
|
||||
// Crashlytics initialization is separated from the main settings initialization
|
||||
// to allow settings customization without Firebase context (e.g. before a Flutter Driver test)
|
||||
Future<void> initFirebase() async {
|
||||
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled);
|
||||
await reportService.setCollectionEnabled(isCrashlyticsEnabled);
|
||||
// cf Android `Settings.Global.TRANSITION_ANIMATION_SCALE`
|
||||
static const platformTransitionAnimationScaleKey = 'transition_animation_scale';
|
||||
|
||||
bool get initialized => _prefs != null;
|
||||
|
||||
Future<void> init({
|
||||
bool isRotationLocked = false,
|
||||
bool areAnimationsRemoved = false,
|
||||
}) async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isRotationLocked = isRotationLocked;
|
||||
_areAnimationsRemoved = areAnimationsRemoved;
|
||||
}
|
||||
|
||||
Future<void> reset({required bool includeInternalKeys}) async {
|
||||
if (includeInternalKeys) {
|
||||
await _prefs!.clear();
|
||||
} else {
|
||||
await Future.forEach(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
|
||||
await Future.forEach<String>(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContextualDefaults() async {
|
||||
// performance
|
||||
final performanceClass = await DeviceService.getPerformanceClass();
|
||||
final performanceClass = await deviceService.getPerformanceClass();
|
||||
enableOverlayBlurEffect = performanceClass >= 30;
|
||||
|
||||
// availability
|
||||
|
@ -139,6 +151,10 @@ class Settings extends ChangeNotifier {
|
|||
final styles = EntryMapStyle.values.whereNot((v) => v.isGoogleMaps).toList();
|
||||
infoMapStyle = styles[Random().nextInt(styles.length)];
|
||||
}
|
||||
|
||||
// accessibility
|
||||
final hasRecommendedTimeouts = await AccessibilityService.hasRecommendedTimeouts();
|
||||
timeToTakeAction = hasRecommendedTimeouts ? AccessibilityTimeout.system : AccessibilityTimeout.appDefault;
|
||||
}
|
||||
|
||||
// app
|
||||
|
@ -147,12 +163,9 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
|
||||
|
||||
bool get isCrashlyticsEnabled => getBoolOrDefault(isCrashlyticsEnabledKey, SettingsDefaults.isCrashlyticsEnabled);
|
||||
bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
|
||||
|
||||
set isCrashlyticsEnabled(bool newValue) {
|
||||
setAndNotify(isCrashlyticsEnabledKey, newValue);
|
||||
unawaited(initFirebase());
|
||||
}
|
||||
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
|
||||
|
||||
static const localeSeparator = '-';
|
||||
|
||||
|
@ -188,10 +201,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
KeepScreenOn get keepScreenOn => getEnumOrDefault(keepScreenOnKey, SettingsDefaults.keepScreenOn, KeepScreenOn.values);
|
||||
|
||||
set keepScreenOn(KeepScreenOn newValue) {
|
||||
setAndNotify(keepScreenOnKey, newValue.toString());
|
||||
newValue.apply();
|
||||
}
|
||||
set keepScreenOn(KeepScreenOn newValue) => setAndNotify(keepScreenOnKey, newValue.toString());
|
||||
|
||||
HomePageSetting get homePage => getEnumOrDefault(homePageKey, SettingsDefaults.homePage, HomePageSetting.values);
|
||||
|
||||
|
@ -242,6 +252,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
||||
|
||||
bool get showThumbnailMotionPhoto => getBoolOrDefault(showThumbnailMotionPhotoKey, SettingsDefaults.showThumbnailMotionPhoto);
|
||||
|
||||
set showThumbnailMotionPhoto(bool newValue) => setAndNotify(showThumbnailMotionPhotoKey, newValue);
|
||||
|
||||
bool get showThumbnailRaw => getBoolOrDefault(showThumbnailRawKey, SettingsDefaults.showThumbnailRaw);
|
||||
|
||||
set showThumbnailRaw(bool newValue) => setAndNotify(showThumbnailRawKey, newValue);
|
||||
|
@ -376,6 +390,16 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
|
||||
|
||||
// accessibility
|
||||
|
||||
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values);
|
||||
|
||||
set accessibilityAnimations(AccessibilityAnimations newValue) => setAndNotify(accessibilityAnimationsKey, newValue.toString());
|
||||
|
||||
AccessibilityTimeout get timeToTakeAction => getEnumOrDefault(timeToTakeActionKey, SettingsDefaults.timeToTakeAction, AccessibilityTimeout.values);
|
||||
|
||||
set timeToTakeAction(AccessibilityTimeout newValue) => setAndNotify(timeToTakeActionKey, newValue.toString());
|
||||
|
||||
// version
|
||||
|
||||
DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs!.getInt(lastVersionCheckDateKey) ?? 0);
|
||||
|
@ -422,6 +446,7 @@ class Settings extends ChangeNotifier {
|
|||
_prefs!.setBool(key, newValue);
|
||||
}
|
||||
if (oldValue != newValue) {
|
||||
_updateStreamController.add(key);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@ -429,29 +454,46 @@ class Settings extends ChangeNotifier {
|
|||
// platform settings
|
||||
|
||||
void _onPlatformSettingsChange(Map? fields) {
|
||||
var changed = false;
|
||||
fields?.forEach((key, value) {
|
||||
switch (key) {
|
||||
// cf Android `Settings.System.ACCELEROMETER_ROTATION`
|
||||
case 'accelerometer_rotation':
|
||||
if (value is int) {
|
||||
case platformAccelerometerRotationKey:
|
||||
if (value is num) {
|
||||
final newValue = value == 0;
|
||||
if (_isRotationLocked != newValue) {
|
||||
_isRotationLocked = newValue;
|
||||
if (!_isRotationLocked) {
|
||||
windowService.requestOrientation();
|
||||
}
|
||||
notifyListeners();
|
||||
_updateStreamController.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case platformTransitionAnimationScaleKey:
|
||||
if (value is num) {
|
||||
final newValue = value == 0;
|
||||
if (_areAnimationsRemoved != newValue) {
|
||||
_areAnimationsRemoved = newValue;
|
||||
_updateStreamController.add(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRotationLocked = false;
|
||||
|
||||
bool get isRotationLocked => _isRotationLocked;
|
||||
|
||||
bool _areAnimationsRemoved = false;
|
||||
|
||||
bool get areAnimationsRemoved => _areAnimationsRemoved;
|
||||
|
||||
// import/export
|
||||
|
||||
String toJson() => jsonEncode(Map.fromEntries(
|
||||
|
@ -492,9 +534,10 @@ class Settings extends ChangeNotifier {
|
|||
debugPrint('failed to import key=$key, value=$value is not a double');
|
||||
}
|
||||
break;
|
||||
case isCrashlyticsEnabledKey:
|
||||
case isErrorReportingEnabledKey:
|
||||
case mustBackTwiceToExitKey:
|
||||
case showThumbnailLocationKey:
|
||||
case showThumbnailMotionPhotoKey:
|
||||
case showThumbnailRawKey:
|
||||
case showThumbnailVideoDurationKey:
|
||||
case showOverlayMinimapKey:
|
||||
|
@ -526,6 +569,8 @@ class Settings extends ChangeNotifier {
|
|||
case infoMapStyleKey:
|
||||
case coordinateFormatKey:
|
||||
case imageBackgroundKey:
|
||||
case accessibilityAnimationsKey:
|
||||
case timeToTakeActionKey:
|
||||
if (value is String) {
|
||||
_prefs!.setString(key, value);
|
||||
} else {
|
||||
|
@ -548,6 +593,7 @@ class Settings extends ChangeNotifier {
|
|||
break;
|
||||
}
|
||||
}
|
||||
_updateStreamController.add(key);
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
|
@ -13,8 +13,6 @@ extension ExtraVideoLoopMode on VideoLoopMode {
|
|||
return context.l10n.videoLoopModeShortOnly;
|
||||
case VideoLoopMode.always:
|
||||
return context.l10n.videoLoopModeAlways;
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -140,6 +140,8 @@ mixin AlbumMixin on SourceBase {
|
|||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateAlbumFilterSummary({Set<AvesEntry>? entries, Set<String?>? directories}) {
|
||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||
|
||||
if (entries == null && directories == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
_filterRecentEntryMap.clear();
|
||||
|
|
|
@ -28,6 +28,7 @@ class CollectionLens with ChangeNotifier {
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource;
|
||||
List<AvesEntry>? fixedSelection;
|
||||
|
||||
List<AvesEntry> _filteredSortedEntries = [];
|
||||
|
||||
|
@ -38,6 +39,7 @@ class CollectionLens with ChangeNotifier {
|
|||
Iterable<CollectionFilter?>? filters,
|
||||
this.id,
|
||||
this.listenToSource = true,
|
||||
this.fixedSelection,
|
||||
}) : filters = (filters ?? {}).whereNotNull().toSet(),
|
||||
sectionFactor = settings.collectionSectionFactor,
|
||||
sortFactor = settings.collectionSortFactor {
|
||||
|
@ -47,6 +49,7 @@ class CollectionLens with ChangeNotifier {
|
|||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<AddressMetadataChangedEvent>().listen((e) {
|
||||
|
@ -117,7 +120,7 @@ class CollectionLens with ChangeNotifier {
|
|||
final bool groupBursts = true;
|
||||
|
||||
void _applyFilters() {
|
||||
final entries = source.visibleEntries;
|
||||
final entries = fixedSelection ?? source.visibleEntries;
|
||||
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
|
||||
|
||||
if (groupBursts) {
|
||||
|
|
|
@ -12,8 +12,8 @@ import 'package:aves/model/source/album.dart';
|
|||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:event_bus/event_bus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -128,9 +128,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
_rawEntries.clear();
|
||||
_invalidate();
|
||||
|
||||
updateDirectories();
|
||||
updateLocations();
|
||||
updateTags();
|
||||
// do not update directories/locations/tags here
|
||||
// as it could reset filter dependent settings (pins, bookmarks, etc.)
|
||||
// caller should take care of updating these at the right time
|
||||
}
|
||||
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
|
||||
|
@ -159,7 +159,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
|
||||
if (newName == entry.filenameWithoutExtension) return true;
|
||||
final newFields = await imageFileService.rename(entry, '$newName${entry.extension}');
|
||||
final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}');
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
await _moveEntry(entry, newFields, persist: persist);
|
||||
|
@ -254,7 +254,17 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<void> refresh();
|
||||
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries);
|
||||
Future<void> rescan(Set<AvesEntry> entries);
|
||||
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) async {
|
||||
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true));
|
||||
|
||||
_invalidate(entries);
|
||||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
eventBus.fire(EntryRefreshedEvent(entries));
|
||||
}
|
||||
|
||||
// monitoring
|
||||
|
||||
|
@ -334,6 +344,12 @@ class EntryMovedEvent {
|
|||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
||||
class EntryRefreshedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRefreshedEvent(this.entries);
|
||||
}
|
||||
|
||||
class FilterVisibilityChangedEvent {
|
||||
final Set<CollectionFilter> filters;
|
||||
final bool visible;
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
@ -18,11 +18,11 @@ mixin LocationMixin on SourceBase {
|
|||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadAddresses();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
|
||||
|
@ -159,6 +159,8 @@ mixin LocationMixin on SourceBase {
|
|||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateCountryFilterSummary([Set<AvesEntry>? entries]) {
|
||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||
|
||||
Set<String>? countryCodes;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:aves/model/favourites.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -25,7 +25,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
await metadataDb.init();
|
||||
await favourites.init();
|
||||
await covers.init();
|
||||
final currentTimeZone = await timeService.getDefaultTimeZone();
|
||||
final currentTimeZone = await deviceService.getDefaultTimeZone();
|
||||
if (currentTimeZone != null) {
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
|
@ -49,21 +49,27 @@ class MediaStoreSource extends CollectionSource {
|
|||
stateNotifier.value = SourceState.loading;
|
||||
clearEntries();
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
||||
final oldEntries = await metadataDb.loadEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
|
||||
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
|
||||
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||
|
||||
// show known entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
|
||||
addEntries(oldEntries);
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata');
|
||||
await loadCatalogMetadata();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
|
||||
await loadAddresses();
|
||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||
|
||||
// clean up obsolete entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
await metadataDb.removeIds(obsoleteContentIds, metadataOnly: false);
|
||||
|
||||
// verify paths because some apps move files without updating their `last modified date`
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
|
||||
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path)));
|
||||
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
|
||||
movedContentIds.forEach((contentId) {
|
||||
|
@ -72,6 +78,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
});
|
||||
|
||||
// fetch new entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
|
||||
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||
var refreshCount = 10;
|
||||
const refreshCountMax = 1000;
|
||||
|
@ -92,22 +99,24 @@ class MediaStoreSource extends CollectionSource {
|
|||
},
|
||||
onDone: () async {
|
||||
addPendingEntries();
|
||||
debugPrint('$runtimeType refresh loaded ${allNewEntries.length} new entries, elapsed=${stopwatch.elapsed}');
|
||||
|
||||
await metadataDb.saveEntries(allNewEntries);
|
||||
|
||||
if (allNewEntries.isNotEmpty) {
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} save new entries');
|
||||
await metadataDb.saveEntries(allNewEntries);
|
||||
|
||||
// new entries include existing entries with obsolete paths
|
||||
// so directories may be added, but also removed or simply have their content summary changed
|
||||
invalidateAlbumFilterSummary();
|
||||
updateDirectories();
|
||||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries');
|
||||
await catalogEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries');
|
||||
await locateEntries();
|
||||
stateNotifier.value = SourceState.ready;
|
||||
|
||||
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}');
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
|
||||
},
|
||||
onError: (error) => debugPrint('$runtimeType stream error=$error'),
|
||||
);
|
||||
|
@ -121,6 +130,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
Future<Set<String>> refreshUris(Set<String> changedUris) async {
|
||||
if (!_initialized || !isMonitoring) return changedUris;
|
||||
|
||||
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
|
||||
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||
final pathSegments = Uri.parse(uri).pathSegments;
|
||||
// e.g. URI `content://media/` has no path segment
|
||||
|
@ -144,7 +154,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
for (final kv in uriByContentId.entries) {
|
||||
final contentId = kv.key;
|
||||
final uri = kv.value;
|
||||
final sourceEntry = await imageFileService.getEntry(uri, null);
|
||||
final sourceEntry = await mediaFileService.getEntry(uri, null);
|
||||
if (sourceEntry != null) {
|
||||
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
|
||||
// compare paths because some apps move files without updating their `last modified date`
|
||||
|
@ -179,9 +189,9 @@ class MediaStoreSource extends CollectionSource {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||
Future<void> rescan(Set<AvesEntry> entries) async {
|
||||
final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet();
|
||||
metadataDb.removeIds(contentIds, metadataOnly: true);
|
||||
await metadataDb.removeIds(contentIds, metadataOnly: true);
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/model/filters/tag.dart';
|
|||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
@ -13,11 +13,11 @@ mixin TagMixin on SourceBase {
|
|||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadMetadataEntries();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
// debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
|
||||
|
@ -70,6 +70,8 @@ mixin TagMixin on SourceBase {
|
|||
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
|
||||
|
||||
void invalidateTagFilterSummary([Set<AvesEntry>? entries]) {
|
||||
if (_filterEntryCountMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
|
||||
|
||||
Set<String>? tags;
|
||||
if (entries == null) {
|
||||
_filterEntryCountMap.clear();
|
||||
|
|
|
@ -10,7 +10,7 @@ import 'package:aves/model/video/profiles/h264.dart';
|
|||
import 'package:aves/model/video/profiles/hevc.dart';
|
||||
import 'package:aves/ref/languages.dart';
|
||||
import 'package:aves/ref/mp4.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
|
|
|
@ -52,6 +52,9 @@ class MimeTypes {
|
|||
static const json = 'application/json';
|
||||
static const plainText = 'text/plain';
|
||||
|
||||
// JB2, JPC, JPX?
|
||||
static const octetStream = 'application/octet-stream';
|
||||
|
||||
// groups
|
||||
|
||||
// formats that support transparency
|
||||
|
@ -60,7 +63,7 @@ class MimeTypes {
|
|||
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
|
||||
|
||||
// TODO TLAD [codec] make it dynamic if it depends on OS/lib versions
|
||||
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
||||
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX, octetStream};
|
||||
|
||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
||||
|
||||
|
|
52
lib/services/accessibility_service.dart
Normal file
52
lib/services/accessibility_service.dart
Normal file
|
@ -0,0 +1,52 @@
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AccessibilityService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/accessibility');
|
||||
|
||||
static Future<bool> areAnimationsRemoved() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('areAnimationsRemoved');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> hasRecommendedTimeouts() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('hasRecommendedTimeouts');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<int> getRecommendedTimeToRead(int originalTimeoutMillis) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getRecommendedTimeoutMillis', <String, dynamic>{
|
||||
'originalTimeoutMillis': originalTimeoutMillis,
|
||||
'content': ['icons', 'text']
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return originalTimeoutMillis;
|
||||
}
|
||||
|
||||
static Future<int> getRecommendedTimeToTakeAction(int originalTimeoutMillis) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getRecommendedTimeoutMillis', <String, dynamic>{
|
||||
'originalTimeoutMillis': originalTimeoutMillis,
|
||||
'content': ['controls', 'icons', 'text']
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return originalTimeoutMillis;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
|
@ -136,4 +138,49 @@ class AndroidAppService {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// app shortcuts
|
||||
|
||||
// this ability will not change over the lifetime of the app
|
||||
static bool? _canPin;
|
||||
|
||||
static Future<bool> canPinToHomeScreen() async {
|
||||
if (_canPin != null) return SynchronousFuture(_canPin!);
|
||||
|
||||
try {
|
||||
final result = await platform.invokeMethod('canPin');
|
||||
if (result != null) {
|
||||
_canPin = result;
|
||||
return result;
|
||||
}
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
|
||||
Uint8List? iconBytes;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await mediaFileService.getThumbnail(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
pageId: entry.pageId,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
dateModifiedSecs: entry.dateModifiedSecs,
|
||||
extent: size,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
'label': label,
|
||||
'iconBytes': iconBytes,
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AndroidDebugService {
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AppShortcutService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/shortcut');
|
||||
|
||||
// this ability will not change over the lifetime of the app
|
||||
static bool? _canPin;
|
||||
|
||||
static Future<bool> canPin() async {
|
||||
if (_canPin != null) return SynchronousFuture(_canPin!);
|
||||
|
||||
try {
|
||||
final result = await platform.invokeMethod('canPin');
|
||||
if (result != null) {
|
||||
_canPin = result;
|
||||
return result;
|
||||
}
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> pin(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
|
||||
Uint8List? iconBytes;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
iconBytes = await imageFileService.getThumbnail(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
pageId: entry.pageId,
|
||||
rotationDegrees: entry.rotationDegrees,
|
||||
isFlipped: entry.isFlipped,
|
||||
dateModifiedSecs: entry.dateModifiedSecs,
|
||||
extent: size,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await platform.invokeMethod('pin', <String, dynamic>{
|
||||
'label': label,
|
||||
'iconBytes': iconBytes,
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
import 'package:aves/model/availability.dart';
|
||||
import 'package:aves/model/metadata_db.dart';
|
||||
import 'package:aves/services/embedded_data_service.dart';
|
||||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/device_service.dart';
|
||||
import 'package:aves/services/media/embedded_data_service.dart';
|
||||
import 'package:aves/services/media/media_file_service.dart';
|
||||
import 'package:aves/services/media/media_store_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_edit_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_fetch_service.dart';
|
||||
import 'package:aves/services/report_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:aves/services/window_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
@ -17,13 +18,14 @@ final p.Context pContext = getIt<p.Context>();
|
|||
final AvesAvailability availability = getIt<AvesAvailability>();
|
||||
final MetadataDb metadataDb = getIt<MetadataDb>();
|
||||
|
||||
final DeviceService deviceService = getIt<DeviceService>();
|
||||
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
|
||||
final ImageFileService imageFileService = getIt<ImageFileService>();
|
||||
final MediaFileService mediaFileService = getIt<MediaFileService>();
|
||||
final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
|
||||
final MetadataService metadataService = getIt<MetadataService>();
|
||||
final MetadataEditService metadataEditService = getIt<MetadataEditService>();
|
||||
final MetadataFetchService metadataFetchService = getIt<MetadataFetchService>();
|
||||
final ReportService reportService = getIt<ReportService>();
|
||||
final StorageService storageService = getIt<StorageService>();
|
||||
final TimeService timeService = getIt<TimeService>();
|
||||
final WindowService windowService = getIt<WindowService>();
|
||||
|
||||
void initPlatformServices() {
|
||||
|
@ -31,12 +33,13 @@ void initPlatformServices() {
|
|||
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
|
||||
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
|
||||
|
||||
getIt.registerLazySingleton<DeviceService>(() => PlatformDeviceService());
|
||||
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
|
||||
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
|
||||
getIt.registerLazySingleton<MediaFileService>(() => PlatformMediaFileService());
|
||||
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
||||
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
|
||||
getIt.registerLazySingleton<MetadataEditService>(() => PlatformMetadataEditService());
|
||||
getIt.registerLazySingleton<MetadataFetchService>(() => PlatformMetadataFetchService());
|
||||
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
|
||||
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
|
||||
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
|
||||
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());
|
||||
}
|
|
@ -1,10 +1,27 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class DeviceService {
|
||||
abstract class DeviceService {
|
||||
Future<String?> getDefaultTimeZone();
|
||||
|
||||
Future<int> getPerformanceClass();
|
||||
}
|
||||
|
||||
class PlatformDeviceService implements DeviceService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/device');
|
||||
|
||||
static Future<int> getPerformanceClass() async {
|
||||
@override
|
||||
Future<String?> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getPerformanceClass() async {
|
||||
try {
|
||||
await platform.invokeMethod('getPerformanceClass');
|
||||
final result = await platform.invokeMethod('getPerformanceClass');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class EmbeddedDataService {
|
|
@ -4,18 +4,16 @@ import 'dart:typed_data';
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/output_buffer.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/output_buffer.dart';
|
||||
import 'package:aves/services/common/service_policy.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class ImageFileService {
|
||||
abstract class MediaFileService {
|
||||
Future<AvesEntry?> getEntry(String uri, String? mimeType);
|
||||
|
||||
Future<Uint8List> getSvg(
|
||||
|
@ -92,18 +90,12 @@ abstract class ImageFileService {
|
|||
});
|
||||
|
||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
||||
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||
}
|
||||
|
||||
class PlatformImageFileService implements ImageFileService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/image');
|
||||
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/image_byte_stream');
|
||||
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/image_op_stream');
|
||||
class PlatformMediaFileService implements MediaFileService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/media_file');
|
||||
static final StreamsChannel _byteStreamChannel = StreamsChannel('deckers.thibault/aves/media_byte_stream');
|
||||
static final StreamsChannel _opStreamChannel = StreamsChannel('deckers.thibault/aves/media_op_stream');
|
||||
static const double thumbnailDefaultSize = 64.0;
|
||||
|
||||
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
|
||||
|
@ -383,62 +375,4 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'clockwise': clockwise,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
|
||||
'shiftMinutes': modifier.shiftMinutes,
|
||||
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
String _toExifInterfaceTag(MetadataField field) {
|
||||
switch (field) {
|
||||
case MetadataField.exifDate:
|
||||
return 'DateTime';
|
||||
case MetadataField.exifDateOriginal:
|
||||
return 'DateTimeOriginal';
|
||||
case MetadataField.exifDateDigitized:
|
||||
return 'DateTimeDigitized';
|
||||
case MetadataField.exifGpsDate:
|
||||
return 'GPSDateStamp';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
|
@ -12,11 +12,14 @@ abstract class MediaStoreService {
|
|||
|
||||
// knownEntries: map of contentId -> dateModifiedSecs
|
||||
Stream<AvesEntry> getEntries(Map<int, int> knownEntries);
|
||||
|
||||
// returns media URI
|
||||
Future<Uri?> scanFile(String path, String mimeType);
|
||||
}
|
||||
|
||||
class PlatformMediaStoreService implements MediaStoreService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/mediastore');
|
||||
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
|
||||
static const platform = MethodChannel('deckers.thibault/aves/media_store');
|
||||
static final StreamsChannel _streamChannel = StreamsChannel('deckers.thibault/aves/media_store_stream');
|
||||
|
||||
@override
|
||||
Future<List<int>> checkObsoleteContentIds(List<int> knownContentIds) async {
|
||||
|
@ -55,4 +58,19 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// returns media URI
|
||||
@override
|
||||
Future<Uri?> scanFile(String path, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('scanFile', <String, dynamic>{
|
||||
'path': path,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return Uri.tryParse(result);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
131
lib/services/metadata/metadata_edit_service.dart
Normal file
131
lib/services/metadata/metadata_edit_service.dart
Normal file
|
@ -0,0 +1,131 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/metadata/date_modifier.dart';
|
||||
import 'package:aves/model/metadata/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class MetadataEditService {
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise});
|
||||
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry);
|
||||
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier);
|
||||
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||
}
|
||||
|
||||
class PlatformMetadataEditService implements MetadataEditService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/metadata_edit');
|
||||
|
||||
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
|
||||
return {
|
||||
'uri': entry.uri,
|
||||
'path': entry.path,
|
||||
'pageId': entry.pageId,
|
||||
'mimeType': entry.mimeType,
|
||||
'width': entry.width,
|
||||
'height': entry.height,
|
||||
'rotationDegrees': entry.rotationDegrees,
|
||||
'isFlipped': entry.isFlipped,
|
||||
'dateModifiedSecs': entry.dateModifiedSecs,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> rotate(AvesEntry entry, {required bool clockwise}) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'clockwise': clockwise,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> flip(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with: 'rotationDegrees' 'isFlipped'
|
||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> editDate(AvesEntry entry, DateModifier modifier) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('editDate', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'dateMillis': modifier.dateTime?.millisecondsSinceEpoch,
|
||||
'shiftMinutes': modifier.shiftMinutes,
|
||||
'fields': modifier.fields.map(_toExifInterfaceTag).toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('removeTypes', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'types': types.map(_toPlatformMetadataType).toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
String _toExifInterfaceTag(MetadataField field) {
|
||||
switch (field) {
|
||||
case MetadataField.exifDate:
|
||||
return 'DateTime';
|
||||
case MetadataField.exifDateOriginal:
|
||||
return 'DateTimeOriginal';
|
||||
case MetadataField.exifDateDigitized:
|
||||
return 'DateTimeDigitized';
|
||||
case MetadataField.exifGpsDate:
|
||||
return 'GPSDateStamp';
|
||||
}
|
||||
}
|
||||
|
||||
String _toPlatformMetadataType(MetadataType type) {
|
||||
switch (type) {
|
||||
case MetadataType.exif:
|
||||
return 'exif';
|
||||
case MetadataType.iccProfile:
|
||||
return 'icc_profile';
|
||||
case MetadataType.iptc:
|
||||
return 'iptc';
|
||||
case MetadataType.jfif:
|
||||
return 'jfif';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'jpeg_adobe';
|
||||
case MetadataType.jpegComment:
|
||||
return 'jpeg_comment';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'jpeg_ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
return 'photoshop_irb';
|
||||
case MetadataType.xmp:
|
||||
return 'xmp';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,12 +3,12 @@ import 'package:aves/model/metadata/catalog.dart';
|
|||
import 'package:aves/model/metadata/overlay.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/panorama.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/service_policy.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class MetadataService {
|
||||
abstract class MetadataFetchService {
|
||||
// returns Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||
Future<Map> getAllMetadata(AvesEntry entry);
|
||||
|
||||
|
@ -25,8 +25,8 @@ abstract class MetadataService {
|
|||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||
}
|
||||
|
||||
class PlatformMetadataService implements MetadataService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/metadata');
|
||||
class PlatformMetadataFetchService implements MetadataFetchService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/metadata_fetch');
|
||||
|
||||
@override
|
||||
Future<Map> getAllMetadata(AvesEntry entry) async {
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/string_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -18,7 +18,7 @@ class SvgMetadataService {
|
|||
|
||||
static Future<Size?> getSize(AvesEntry entry) async {
|
||||
try {
|
||||
final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
|
||||
final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
|
||||
|
||||
final document = XmlDocument.parse(utf8.decode(data));
|
||||
final root = document.rootElement;
|
||||
|
@ -64,7 +64,7 @@ class SvgMetadataService {
|
|||
}
|
||||
|
||||
try {
|
||||
final data = await imageFileService.getSvg(entry.uri, entry.mimeType);
|
||||
final data = await mediaFileService.getSvg(entry.uri, entry.mimeType);
|
||||
|
||||
final document = XmlDocument.parse(utf8.decode(data));
|
||||
final root = document.rootElement;
|
|
@ -1,10 +1,13 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stack_trace/stack_trace.dart';
|
||||
|
||||
abstract class ReportService {
|
||||
Future<void> init();
|
||||
|
||||
bool get isCollectionEnabled;
|
||||
|
||||
Future<void> setCollectionEnabled(bool enabled);
|
||||
|
@ -21,23 +24,29 @@ abstract class ReportService {
|
|||
}
|
||||
|
||||
class CrashlyticsReportService extends ReportService {
|
||||
FirebaseCrashlytics get instance => FirebaseCrashlytics.instance;
|
||||
FirebaseCrashlytics get _instance => FirebaseCrashlytics.instance;
|
||||
|
||||
@override
|
||||
bool get isCollectionEnabled => instance.isCrashlyticsCollectionEnabled;
|
||||
Future<void> init() => Firebase.initializeApp();
|
||||
|
||||
@override
|
||||
Future<void> setCollectionEnabled(bool enabled) => instance.setCrashlyticsCollectionEnabled(enabled);
|
||||
bool get isCollectionEnabled => _instance.isCrashlyticsCollectionEnabled;
|
||||
|
||||
@override
|
||||
Future<void> log(String message) => instance.log(message);
|
||||
Future<void> setCollectionEnabled(bool enabled) async {
|
||||
debugPrint('${enabled ? 'enable' : 'disable'} Firebase & Crashlytics collection');
|
||||
await Firebase.app().setAutomaticDataCollectionEnabled(enabled);
|
||||
await _instance.setCrashlyticsCollectionEnabled(enabled);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setCustomKey(String key, Object value) => instance.setCustomKey(key, value);
|
||||
Future<void> log(String message) => _instance.log(message);
|
||||
|
||||
@override
|
||||
Future<void> setCustomKey(String key, Object value) => _instance.setCustomKey(key, value);
|
||||
|
||||
@override
|
||||
Future<void> setCustomKeys(Map<String, Object> map) {
|
||||
final _instance = instance;
|
||||
return Future.forEach<MapEntry<String, Object>>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value));
|
||||
}
|
||||
|
||||
|
@ -56,11 +65,11 @@ class CrashlyticsReportService extends ReportService {
|
|||
)
|
||||
.join('\n'));
|
||||
}
|
||||
return instance.recordError(exception, stack);
|
||||
return _instance.recordError(exception, stack);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) {
|
||||
return instance.recordFlutterError(flutterErrorDetails);
|
||||
return _instance.recordFlutterError(flutterErrorDetails);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/services/output_buffer.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/output_buffer.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
|
@ -15,20 +14,17 @@ abstract class StorageService {
|
|||
|
||||
Future<List<String>> getGrantedDirectories();
|
||||
|
||||
Future<void> revokeDirectoryAccess(String path);
|
||||
|
||||
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths);
|
||||
|
||||
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories();
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
Future<bool> requestVolumeAccess(String volumePath);
|
||||
Future<void> revokeDirectoryAccess(String path);
|
||||
|
||||
// returns number of deleted directories
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||
|
||||
// returns media URI
|
||||
Future<Uri?> scanFile(String path, String mimeType);
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
Future<bool> requestVolumeAccess(String volumePath);
|
||||
|
||||
// return whether operation succeeded (`null` if user cancelled)
|
||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||
|
@ -77,18 +73,6 @@ class PlatformStorageService implements StorageService {
|
|||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeDirectoryAccess(String path) async {
|
||||
try {
|
||||
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
||||
'path': path,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
|
@ -117,6 +101,32 @@ class PlatformStorageService implements StorageService {
|
|||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeDirectoryAccess(String path) async {
|
||||
try {
|
||||
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
||||
'path': path,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// returns number of deleted directories
|
||||
@override
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
@override
|
||||
Future<bool> requestVolumeAccess(String volumePath) async {
|
||||
|
@ -140,36 +150,6 @@ class PlatformStorageService implements StorageService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// returns number of deleted directories
|
||||
@override
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// returns media URI
|
||||
@override
|
||||
Future<Uri?> scanFile(String path, String mimeType) async {
|
||||
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
||||
try {
|
||||
final result = await platform.invokeMethod('scanFile', <String, dynamic>{
|
||||
'path': path,
|
||||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return Uri.tryParse(result);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
|
||||
try {
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class TimeService {
|
||||
Future<String?> getDefaultTimeZone();
|
||||
}
|
||||
|
||||
class PlatformTimeService implements TimeService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/time');
|
||||
|
||||
@override
|
||||
Future<String?> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ViewerService {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:aves/model/settings/accessibility_animations.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Durations {
|
||||
// Flutter animations (with margin)
|
||||
|
@ -10,12 +13,8 @@ class Durations {
|
|||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
||||
|
||||
// common animations
|
||||
static const iconAnimation = Duration(milliseconds: 300);
|
||||
static const sweeperOpacityAnimation = Duration(milliseconds: 150);
|
||||
static const sweepingAnimation = Duration(milliseconds: 650);
|
||||
|
||||
static const staggeredAnimation = Duration(milliseconds: 375);
|
||||
static const staggeredAnimationPageTarget = Duration(milliseconds: 800);
|
||||
static const dialogFieldReachAnimation = Duration(milliseconds: 300);
|
||||
|
||||
static const appBarTitleAnimation = Duration(milliseconds: 300);
|
||||
|
@ -40,9 +39,6 @@ class Durations {
|
|||
static const filterRowExpandAnimation = Duration(milliseconds: 300);
|
||||
|
||||
// viewer animations
|
||||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
|
||||
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200);
|
||||
static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150);
|
||||
static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
|
||||
|
@ -56,7 +52,7 @@ class Durations {
|
|||
static const quickActionHighlightAnimation = Duration(milliseconds: 200);
|
||||
|
||||
// delays & refresh intervals
|
||||
static const opToastDisplay = Duration(seconds: 3);
|
||||
static const opToastTextDisplay = Duration(seconds: 3);
|
||||
static const opToastActionDisplay = Duration(seconds: 5);
|
||||
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
|
||||
|
@ -64,14 +60,73 @@ class Durations {
|
|||
static const highlightScrollInitDelay = Duration(milliseconds: 800);
|
||||
static const videoOverlayHideDelay = Duration(milliseconds: 500);
|
||||
static const videoProgressTimerInterval = Duration(milliseconds: 300);
|
||||
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
|
||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
|
||||
static const mapScrollDebounceDelay = Duration(milliseconds: 150);
|
||||
static const mapInfoDebounceDelay = Duration(milliseconds: 150);
|
||||
static const mapIdleDebounceDelay = Duration(milliseconds: 100);
|
||||
|
||||
// app life
|
||||
static const lastVersionCheckInterval = Duration(days: 7);
|
||||
}
|
||||
|
||||
class DurationsProvider extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const DurationsProvider({
|
||||
Key? key,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProxyProvider<Settings, DurationsData>(
|
||||
update: (context, settings, __) {
|
||||
final enabled = settings.accessibilityAnimations.animate;
|
||||
return enabled ? DurationsData() : DurationsData.noAnimation();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class DurationsData {
|
||||
// common animations
|
||||
final Duration expansionTileAnimation;
|
||||
final Duration iconAnimation;
|
||||
final Duration staggeredAnimation;
|
||||
final Duration staggeredAnimationPageTarget;
|
||||
|
||||
// viewer animations
|
||||
final Duration viewerVerticalPageScrollAnimation;
|
||||
final Duration viewerOverlayAnimation;
|
||||
final Duration viewerOverlayChangeAnimation;
|
||||
|
||||
// delays & refresh intervals
|
||||
final Duration staggeredAnimationDelay;
|
||||
|
||||
const DurationsData({
|
||||
this.expansionTileAnimation = const Duration(milliseconds: 200),
|
||||
this.iconAnimation = const Duration(milliseconds: 300),
|
||||
this.staggeredAnimation = const Duration(milliseconds: 375),
|
||||
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
|
||||
this.viewerVerticalPageScrollAnimation = const Duration(milliseconds: 500),
|
||||
this.viewerOverlayAnimation = const Duration(milliseconds: 200),
|
||||
this.viewerOverlayChangeAnimation = const Duration(milliseconds: 150),
|
||||
}) : staggeredAnimationDelay = staggeredAnimation ~/ 6;
|
||||
|
||||
factory DurationsData.noAnimation() {
|
||||
return DurationsData(
|
||||
// as of Flutter v2.5.1, `ExpansionPanelList` throws if animation duration is zero
|
||||
expansionTileAnimation: const Duration(microseconds: 1),
|
||||
iconAnimation: Duration.zero,
|
||||
staggeredAnimation: Duration.zero,
|
||||
staggeredAnimationPageTarget: Duration.zero,
|
||||
viewerVerticalPageScrollAnimation: Duration.zero,
|
||||
viewerOverlayAnimation: Duration.zero,
|
||||
viewerOverlayChangeAnimation: Duration.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ class AIcons {
|
|||
static const IconData video = Icons.movie_outlined;
|
||||
static const IconData vector = Icons.code_outlined;
|
||||
|
||||
static const IconData accessibility = Icons.accessibility_new_outlined;
|
||||
static const IconData android = Icons.android;
|
||||
static const IconData broken = Icons.broken_image_outlined;
|
||||
static const IconData checked = Icons.done_outlined;
|
||||
|
|
|
@ -17,25 +17,24 @@ class Themes {
|
|||
|
||||
static final darkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: _accentColor,
|
||||
// canvas color is used as background for the drawer and popups
|
||||
// when using a popup menu on a dialog, lighten the background via `PopupMenuTheme`
|
||||
canvasColor: Colors.grey[850],
|
||||
scaffoldBackgroundColor: Colors.grey.shade900,
|
||||
dialogBackgroundColor: Colors.grey[850],
|
||||
indicatorColor: _accentColor,
|
||||
toggleableActiveColor: _accentColor,
|
||||
tooltipTheme: const TooltipThemeData(
|
||||
verticalOffset: 32,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
textTheme: TextTheme(
|
||||
headline6: TextStyle(
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.grey.shade900,
|
||||
titleTextStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontFeatures: [FontFeature.enable('smcp')],
|
||||
),
|
||||
),
|
||||
),
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _accentColor,
|
||||
secondary: _accentColor,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
|
27
lib/utils/geo_utils.dart
Normal file
27
lib/utils/geo_utils.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
LatLng getLatLngCenter(List<LatLng> points) {
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
double z = 0;
|
||||
|
||||
points.forEach((point) {
|
||||
final lat = point.latitudeInRad;
|
||||
final lng = point.longitudeInRad;
|
||||
x += cos(lat) * cos(lng);
|
||||
y += cos(lat) * sin(lng);
|
||||
z += sin(lat);
|
||||
});
|
||||
|
||||
final pointCount = points.length;
|
||||
x /= pointCount;
|
||||
y /= pointCount;
|
||||
z /= pointCount;
|
||||
|
||||
final lng = atan2(y, x);
|
||||
final hyp = sqrt(x * x + y * y);
|
||||
final lat = atan2(z, hyp);
|
||||
return LatLng(radianToDeg(lat), radianToDeg(lng));
|
||||
}
|
|
@ -1,11 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
const double _piOver180 = pi / 180.0;
|
||||
|
||||
double toDegrees(num radians) => radians / _piOver180;
|
||||
|
||||
double toRadians(num degrees) => degrees * _piOver180;
|
||||
|
||||
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / ln2).floor()).toInt();
|
||||
|
||||
int smallestPowerOf2(num x) => x < 1 ? 1 : pow(2, (log(x) / ln2).ceil()).toInt();
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
// cf https://github.com/google/pedantic/blob/master/lib/pedantic.dart
|
||||
void unawaited(Future<void>? future) {}
|
|
@ -4,16 +4,19 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:aves/flutter_version.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/identity/buttons.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class BugReport extends StatefulWidget {
|
||||
|
@ -36,10 +39,12 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.expansionTileAnimation);
|
||||
return ExpansionPanelList(
|
||||
expansionCallback: (index, isExpanded) {
|
||||
setState(() => _showInstructions = !isExpanded);
|
||||
},
|
||||
animationDuration: animationDuration,
|
||||
expandedHeaderPadding: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
children: [
|
||||
|
@ -84,7 +89,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
),
|
||||
isExpanded: _showInstructions,
|
||||
canTapOnHeader: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -99,7 +104,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: Theme.of(context).accentColor,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
width: AvesFilterChip.outlineWidth,
|
||||
)),
|
||||
shape: BoxShape.circle,
|
||||
|
@ -109,13 +114,9 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(text)),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
AvesOutlinedButton(
|
||||
label: buttonText,
|
||||
onPressed: onPressed,
|
||||
style: ButtonStyle(
|
||||
side: MaterialStateProperty.all<BorderSide>(BorderSide(color: Theme.of(context).accentColor)),
|
||||
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
|
||||
),
|
||||
child: Text(buttonText),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/utils/constants.dart';
|
|||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:aves/widgets/common/identity/buttons.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
@ -69,7 +70,8 @@ class _LicensesState extends State<Licenses> {
|
|||
children: _dartPackages.map((package) => LicenseRow(package: package)).toList(),
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
child: AvesOutlinedButton(
|
||||
label: context.l10n.aboutLicensesShowAllButtonLabel,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
@ -82,7 +84,6 @@ class _LicensesState extends State<Licenses> {
|
|||
),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.aboutLicensesShowAllButtonLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/about/news_badge.dart';
|
||||
import 'package:aves/widgets/common/basic/link_chip.dart';
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/settings/accessibility_animations.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/media_store_source.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/accessibility_service.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
|
@ -16,13 +19,13 @@ 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:equatable/equatable.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:overlay_support/overlay_support.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class AvesApp extends StatefulWidget {
|
||||
const AvesApp({Key? key}) : super(key: key);
|
||||
|
@ -41,7 +44,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
// observers are not registered when using the same list object with different items
|
||||
// the list itself needs to be reassigned
|
||||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange');
|
||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
|
||||
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
|
||||
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
@ -68,6 +71,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
value: appModeNotifier,
|
||||
child: Provider<CollectionSource>.value(
|
||||
value: _mediaStoreSource,
|
||||
child: DurationsProvider(
|
||||
child: HighlightInfoProvider(
|
||||
child: OverlaySupport(
|
||||
child: FutureBuilder<void>(
|
||||
|
@ -79,13 +83,27 @@ class _AvesAppState extends State<AvesApp> {
|
|||
: Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error!) : const SizedBox(),
|
||||
);
|
||||
return Selector<Settings, Locale?>(
|
||||
selector: (context, s) => s.locale,
|
||||
builder: (context, settingsLocale, child) {
|
||||
return Selector<Settings, Tuple2<Locale?, bool>>(
|
||||
selector: (context, s) => Tuple2(s.locale, s.initialized ? s.accessibilityAnimations.animate : true),
|
||||
builder: (context, s, child) {
|
||||
final settingsLocale = s.item1;
|
||||
final areAnimationsEnabled = s.item2;
|
||||
return MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
home: home,
|
||||
navigatorObservers: _navigatorObservers,
|
||||
builder: (context, child) {
|
||||
if (!areAnimationsEnabled) {
|
||||
child = Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
// strip page transitions used by `MaterialPageRoute`
|
||||
pageTransitionsTheme: DirectPageTransitionsTheme(),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
}
|
||||
return child!;
|
||||
},
|
||||
onGenerateTitle: (context) => context.l10n.appName,
|
||||
darkTheme: Themes.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
|
@ -97,8 +115,10 @@ class _AvesAppState extends State<AvesApp> {
|
|||
// checkerboardRasterCacheImages: true,
|
||||
// checkerboardOffscreenLayers: true,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -123,7 +143,24 @@ class _AvesAppState extends State<AvesApp> {
|
|||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) async {
|
||||
await settings.init(
|
||||
isRotationLocked: await windowService.isRotationLocked(),
|
||||
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||
);
|
||||
|
||||
// keep screen on
|
||||
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen(
|
||||
(_) => settings.keepScreenOn.apply(),
|
||||
);
|
||||
settings.keepScreenOn.apply();
|
||||
|
||||
// error reporting
|
||||
await reportService.init();
|
||||
settings.updateStream.where((key) => key == Settings.isErrorReportingEnabledKey).listen(
|
||||
(_) => reportService.setCollectionEnabled(settings.isErrorReportingEnabled),
|
||||
);
|
||||
await reportService.setCollectionEnabled(settings.isErrorReportingEnabled);
|
||||
|
||||
FlutterError.onError = reportService.recordFlutterError;
|
||||
final now = DateTime.now();
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
|
@ -137,11 +174,8 @@ class _AvesAppState extends State<AvesApp> {
|
|||
'locales': window.locales.join(', '),
|
||||
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
||||
});
|
||||
});
|
||||
await settings.init();
|
||||
await settings.initFirebase();
|
||||
_navigatorObservers = [
|
||||
CrashlyticsRouteTracker(),
|
||||
ReportingRouteTracker(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -9,9 +9,8 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/app_shortcut_service.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
|
@ -58,11 +57,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
void initState() {
|
||||
super.initState();
|
||||
_browseToSelectAnimation = AnimationController(
|
||||
duration: Durations.iconAnimation,
|
||||
duration: context.read<DurationsData>().iconAnimation,
|
||||
vsync: this,
|
||||
);
|
||||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
_canAddShortcutsLoader = AppShortcutService.canPin();
|
||||
_canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
||||
}
|
||||
|
@ -243,9 +242,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
]
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
onSelected: (action) async {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
await _onCollectionActionSelected(action);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -290,7 +290,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case EntrySetAction.delete:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.refreshMetadata:
|
||||
case EntrySetAction.rescan:
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
_actionDelegate.onActionSelected(context, action);
|
||||
|
@ -371,7 +371,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(AppShortcutService.pin(name, coverEntry, filters));
|
||||
unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters));
|
||||
}
|
||||
|
||||
void _goToSearch() {
|
||||
|
|
|
@ -79,18 +79,18 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
|
||||
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
|
||||
builder: (context, tileExtent, child) {
|
||||
return GridTheme(
|
||||
extent: tileExtent,
|
||||
child: Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
return Selector<TileExtentController, Tuple3<double, int, double>>(
|
||||
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
|
||||
builder: (context, c, child) {
|
||||
final scrollableWidth = c.item1;
|
||||
final columnCount = c.item2;
|
||||
final tileSpacing = c.item3;
|
||||
// do not listen for animation delay change
|
||||
final controller = Provider.of<TileExtentController>(context, listen: false);
|
||||
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
|
||||
return SectionedEntryListLayoutProvider(
|
||||
final target = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||
final tileAnimationDelay = context.read<TileExtentController>().getTileAnimationDelay(target);
|
||||
return GridTheme(
|
||||
extent: tileExtent,
|
||||
child: SectionedEntryListLayoutProvider(
|
||||
collection: collection,
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
|
@ -104,17 +104,19 @@ class _CollectionGridContent extends StatelessWidget {
|
|||
isScrollingNotifier: _isScrollingNotifier,
|
||||
),
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _CollectionSectionedContent(
|
||||
collection: collection,
|
||||
isScrollingNotifier: _isScrollingNotifier,
|
||||
scrollController: PrimaryScrollController.of(context)!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return sectionedListLayoutProvider;
|
||||
},
|
||||
);
|
||||
|
@ -199,10 +201,11 @@ class _CollectionScaler extends StatelessWidget {
|
|||
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
|
||||
return GridScaleGestureDetector<AvesEntry>(
|
||||
scrollableKey: scrollableKey,
|
||||
gridBuilder: (center, extent, child) => CustomPaint(
|
||||
heightForWidth: (width) => width,
|
||||
gridBuilder: (center, tileSize, child) => CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: center,
|
||||
extent: extent,
|
||||
tileSize: tileSize,
|
||||
spacing: tileSpacing,
|
||||
borderWidth: DecoratedThumbnail.borderWidth,
|
||||
borderRadius: Radius.zero,
|
||||
|
@ -210,7 +213,7 @@ class _CollectionScaler extends StatelessWidget {
|
|||
),
|
||||
child: child,
|
||||
),
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
scaledBuilder: (entry, tileSize) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
|
||||
selectable: false,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue