Merge branch 'develop'
This commit is contained in:
commit
5fbc9eebe5
174 changed files with 4115 additions and 2258 deletions
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- uses: subosito/flutter-action@v1
|
- uses: subosito/flutter-action@v1
|
||||||
with:
|
with:
|
||||||
channel: stable
|
channel: stable
|
||||||
flutter-version: '1.22.5'
|
flutter-version: '1.22.6'
|
||||||
|
|
||||||
- name: Clone the repository.
|
- name: Clone the repository.
|
||||||
uses: actions/checkout@v2
|
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
|
- uses: subosito/flutter-action@v1
|
||||||
with:
|
with:
|
||||||
channel: stable
|
channel: stable
|
||||||
flutter-version: '1.22.5'
|
flutter-version: '1.22.6'
|
||||||
|
|
||||||
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
|
||||||
# https://issuetracker.google.com/issues/144111441
|
# https://issuetracker.google.com/issues/144111441
|
||||||
|
@ -50,8 +50,8 @@ jobs:
|
||||||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||||
rm release.keystore.asc
|
rm release.keystore.asc
|
||||||
flutter build apk --bundle-sksl-path shaders_1.22.5.sksl.json
|
flutter build apk --bundle-sksl-path shaders_1.22.6.sksl.json
|
||||||
flutter build appbundle --bundle-sksl-path shaders_1.22.5.sksl.json
|
flutter build appbundle --bundle-sksl-path shaders_1.22.6.sksl.json
|
||||||
rm $AVES_STORE_FILE
|
rm $AVES_STORE_FILE
|
||||||
env:
|
env:
|
||||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||||
|
|
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [v1.3.3] - 2021-01-31
|
||||||
|
### Added
|
||||||
|
- Viewer: support for multi-track HEIF
|
||||||
|
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
|
||||||
|
- Info: show owner app (Android Q and up)
|
||||||
|
- listen to Media Store changes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- upgraded Flutter to stable v1.22.6
|
||||||
|
- check connectivity before using features that need it
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- checkerboard background performance
|
||||||
|
- deleting files that no longer exist but are still registered in the Media Store
|
||||||
|
- insets handling on Android 11
|
||||||
|
|
||||||
## [v1.3.2] - 2021-01-17
|
## [v1.3.2] - 2021-01-17
|
||||||
### Added
|
### Added
|
||||||
Collection: identify multipage TIFF & multitrack HEIC/HEIF
|
Collection: identify multipage TIFF & multitrack HEIC/HEIF
|
||||||
|
|
|
@ -12,7 +12,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- support raster images: JPEG, GIF, PNG, HEIC (from Android Pie), WEBP, TIFF, BMP, WBMP, ICO
|
- support raster images: JPEG, GIF, PNG, HEIC/HEIF (including multi-track, from Android Pie), WEBP, TIFF (including multi-page), BMP, WBMP, ICO
|
||||||
- support animated images: GIF, WEBP
|
- support animated images: GIF, WEBP
|
||||||
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
|
- support raw images: ARW, CR2, DNG, NEF, NRW, ORF, PEF, RAF, RW2, SRW
|
||||||
- support vector images: SVG
|
- support vector images: SVG
|
||||||
|
@ -36,10 +36,10 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
||||||
|
|
||||||
| Model | Name | Android Version | API |
|
| Model | Name | Android Version | API |
|
||||||
| ----------- | -------------------------- | --------------- | ---:|
|
| ----------- | -------------------------- | --------------- | ---:|
|
||||||
| SM-G970N | Samsung Galaxy S10e | 10 (Android10) | 29 |
|
| SM-G981N | Samsung Galaxy S20 5G | 11 | 30 |
|
||||||
|
| SM-G970N | Samsung Galaxy S10e | 10 (Q) | 29 |
|
||||||
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
|
| SM-P580 | Samsung Galaxy Tab A 10.1 | 8.1.0 (Oreo) | 27 |
|
||||||
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
|
| SM-G930S | Samsung Galaxy S7 | 8.0.0 (Oreo) | 26 |
|
||||||
| E5823 | Sony Xperia Z5 Compact | 7.1.1 (Nougat) | 25 |
|
|
||||||
|
|
||||||
## Project Setup
|
## Project Setup
|
||||||
|
|
||||||
|
|
|
@ -98,15 +98,15 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||||
implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
implementation 'androidx.core:core-ktx:1.5.0-beta01' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.15.0'
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:f87db4305d' // forked, built by JitPack
|
||||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.1.0'
|
kapt 'androidx.annotation:annotation:1.1.0'
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||||
|
|
||||||
compileOnly rootProject.findProject(':streams_channel')
|
compileOnly rootProject.findProject(':streams_channel')
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,6 @@
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
|
|
|
@ -16,24 +16,18 @@ import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
companion object {
|
private lateinit var contentStreamHandler: ContentChangeStreamHandler
|
||||||
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||||
const val INTENT_CHANNEL = "deckers.thibault/aves/intent"
|
|
||||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val intentStreamHandler = IntentStreamHandler()
|
|
||||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
intentDataMap = extractIntentData(intent)
|
|
||||||
|
|
||||||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||||
|
|
||||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||||
|
@ -48,59 +42,34 @@ class MainActivity : FlutterActivity() {
|
||||||
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
||||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||||
|
|
||||||
|
// Media Store change monitoring
|
||||||
|
contentStreamHandler = ContentChangeStreamHandler(this).apply {
|
||||||
|
EventChannel(messenger, ContentChangeStreamHandler.CHANNEL).setStreamHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intent handling
|
||||||
|
intentStreamHandler = IntentStreamHandler().apply {
|
||||||
|
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
|
||||||
|
}
|
||||||
|
intentDataMap = extractIntentData(intent)
|
||||||
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getIntentData" -> {
|
"getIntentData" -> {
|
||||||
result.success(intentDataMap)
|
result.success(intentDataMap)
|
||||||
intentDataMap.clear()
|
intentDataMap.clear()
|
||||||
}
|
}
|
||||||
"pick" -> {
|
"pick" -> pick(call)
|
||||||
val pickedUri = call.argument<String>("uri")
|
|
||||||
if (pickedUri != null) {
|
|
||||||
val intent = Intent().apply {
|
|
||||||
data = Uri.parse(pickedUri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, intent)
|
|
||||||
} else {
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
}
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventChannel(messenger, INTENT_CHANNEL).setStreamHandler(intentStreamHandler)
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
setupShortcuts()
|
setupShortcuts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
override fun onDestroy() {
|
||||||
private fun setupShortcuts() {
|
contentStreamHandler.dispose()
|
||||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
super.onDestroy()
|
||||||
|
|
||||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
|
||||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
|
||||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
|
||||||
.setIntent(
|
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
|
||||||
.putExtra("page", "/search")
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
|
||||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
|
||||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
|
||||||
.setIntent(
|
|
||||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
|
||||||
.putExtra("page", "/collection")
|
|
||||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
@ -109,6 +78,25 @@ class MainActivity : FlutterActivity() {
|
||||||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
||||||
|
val treeUri = data?.data
|
||||||
|
if (resultCode != RESULT_OK || treeUri == null) {
|
||||||
|
PermissionManager.onPermissionResult(requestCode, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// save access permissions across reboots
|
||||||
|
val takeFlags = (data.flags
|
||||||
|
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
||||||
|
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||||
|
|
||||||
|
// resume pending action
|
||||||
|
PermissionManager.onPermissionResult(requestCode, treeUri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_MAIN -> {
|
Intent.ACTION_MAIN -> {
|
||||||
|
@ -138,22 +126,48 @@ class MainActivity : FlutterActivity() {
|
||||||
return HashMap()
|
return HashMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
private fun pick(call: MethodCall) {
|
||||||
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
|
val pickedUri = call.argument<String>("uri")
|
||||||
val treeUri = data?.data
|
if (pickedUri != null) {
|
||||||
if (resultCode != RESULT_OK || treeUri == null) {
|
val intent = Intent().apply {
|
||||||
PermissionManager.onPermissionResult(requestCode, null)
|
data = Uri.parse(pickedUri)
|
||||||
return
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
setResult(RESULT_OK, intent)
|
||||||
|
} else {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
// save access permissions across reboots
|
@RequiresApi(Build.VERSION_CODES.N_MR1)
|
||||||
val takeFlags = (data.flags
|
private fun setupShortcuts() {
|
||||||
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
|
|
||||||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
|
||||||
|
|
||||||
// resume pending action
|
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||||
PermissionManager.onPermissionResult(requestCode, treeUri)
|
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||||
|
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||||
|
.setIntent(
|
||||||
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
|
.putExtra("page", "/search")
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||||
|
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||||
|
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||||
|
.setIntent(
|
||||||
|
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||||
|
.putExtra("page", "/collection")
|
||||||
|
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(MainActivity::class.java)
|
||||||
|
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,6 +12,8 @@ import androidx.core.content.FileProvider
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -28,8 +30,8 @@ import kotlin.math.roundToInt
|
||||||
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) }
|
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
|
||||||
"getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) }
|
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAppIcon) }
|
||||||
"edit" -> {
|
"edit" -> {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
@ -61,30 +63,24 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppNames(result: MethodChannel.Result) {
|
private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
val nameMap = HashMap<String, String>()
|
val packages = HashMap<String, FieldMap>()
|
||||||
val intent = Intent(Intent.ACTION_MAIN, null)
|
|
||||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
|
||||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
|
||||||
|
|
||||||
|
fun addPackageDetails(intent: Intent) {
|
||||||
// apps tend to use their name in English when creating folders
|
// apps tend to use their name in English when creating folders
|
||||||
// so we get their names in English as well as the current locale
|
// so we get their names in English as well as the current locale
|
||||||
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
|
||||||
|
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
|
||||||
val ai = resolveInfo.activityInfo.applicationInfo
|
val appInfo = resolveInfo.activityInfo.applicationInfo
|
||||||
val isSystemPackage = ai.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
val packageName = appInfo.packageName
|
||||||
if (!isSystemPackage) {
|
if (!packages.containsKey(packageName)) {
|
||||||
val packageName = ai.packageName
|
val currentLabel = pm.getApplicationLabel(appInfo).toString()
|
||||||
|
val englishLabel: String? = appInfo.labelRes.takeIf { it != 0 }?.let { labelRes ->
|
||||||
val currentLabel = pm.getApplicationLabel(ai).toString()
|
var englishLabel: String? = null
|
||||||
nameMap[currentLabel] = packageName
|
|
||||||
|
|
||||||
val labelRes = ai.labelRes
|
|
||||||
if (labelRes != 0) {
|
|
||||||
try {
|
try {
|
||||||
val resources = pm.getResourcesForApplication(ai)
|
val resources = pm.getResourcesForApplication(appInfo)
|
||||||
// `updateConfiguration` is deprecated but it seems to be the only way
|
// `updateConfiguration` is deprecated but it seems to be the only way
|
||||||
// to query resources from another app with a specific locale.
|
// to query resources from another app with a specific locale.
|
||||||
// The following methods do not work:
|
// The following methods do not work:
|
||||||
|
@ -92,15 +88,26 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
|
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
||||||
val englishLabel = resources.getString(labelRes)
|
englishLabel = resources.getString(labelRes)
|
||||||
nameMap[englishLabel] = packageName
|
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
|
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
|
||||||
}
|
}
|
||||||
|
englishLabel
|
||||||
|
}
|
||||||
|
packages[packageName] = hashMapOf(
|
||||||
|
"packageName" to packageName,
|
||||||
|
"categoryLauncher" to intent.hasCategory(Intent.CATEGORY_LAUNCHER),
|
||||||
|
"isSystem" to (appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0),
|
||||||
|
"currentLabel" to currentLabel,
|
||||||
|
"englishLabel" to englishLabel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(nameMap)
|
|
||||||
|
addPackageDetails(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER))
|
||||||
|
addPackageDetails(Intent(Intent.ACTION_MAIN))
|
||||||
|
result.success(ArrayList(packages.values))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.reflect.KSuspendFunction2
|
||||||
|
|
||||||
// ensure `result` methods are called on the main looper thread
|
// ensure `result` methods are called on the main looper thread
|
||||||
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
|
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
|
||||||
|
@ -20,4 +22,24 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu
|
||||||
override fun notImplemented() {
|
override fun notImplemented() {
|
||||||
mainScope.launch { methodResult.notImplemented() }
|
mainScope.launch { methodResult.notImplemented() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
|
||||||
|
val res = Coresult(result)
|
||||||
|
try {
|
||||||
|
function(call, res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
res.error("safe-exception", e.message, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
|
||||||
|
val res = Coresult(result)
|
||||||
|
try {
|
||||||
|
function(call, res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
res.error("safe-exception", e.message, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,11 @@ import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.drew.imaging.ImageMetadataReader
|
import com.drew.imaging.ImageMetadataReader
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
|
@ -37,12 +38,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getContextDirs" -> result.success(getContextDirs())
|
"getContextDirs" -> result.success(getContextDirs())
|
||||||
"getEnv" -> result.success(System.getenv())
|
"getEnv" -> result.success(System.getenv())
|
||||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) }
|
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
||||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) }
|
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
|
||||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) }
|
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||||
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) }
|
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
|
||||||
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) }
|
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,13 @@ import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||||
|
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||||
|
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.ExifOrientationOp
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||||
|
@ -26,17 +31,14 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) }
|
"getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getObsoleteEntries) }
|
||||||
"getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) }
|
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getEntry) }
|
||||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) }
|
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getThumbnail) }
|
||||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) }
|
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRegion) }
|
||||||
"clearSizedThumbnailDiskCache" -> {
|
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||||
GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() }
|
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
|
||||||
result.success(null)
|
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||||
}
|
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) }
|
|
||||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { rotate(call, Coresult(result)) }
|
|
||||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { flip(call, Coresult(result)) }
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,7 +60,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
val isFlipped = call.argument<Boolean>("isFlipped")
|
val isFlipped = call.argument<Boolean>("isFlipped")
|
||||||
val widthDip = call.argument<Double>("widthDip")
|
val widthDip = call.argument<Double>("widthDip")
|
||||||
val heightDip = call.argument<Double>("heightDip")
|
val heightDip = call.argument<Double>("heightDip")
|
||||||
val page = call.argument<Int>("page")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
|
||||||
|
|
||||||
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
|
||||||
|
@ -76,7 +78,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
isFlipped,
|
isFlipped,
|
||||||
width = (widthDip * density).roundToInt(),
|
width = (widthDip * density).roundToInt(),
|
||||||
height = (heightDip * density).roundToInt(),
|
height = (heightDip * density).roundToInt(),
|
||||||
page = page,
|
pageId = pageId,
|
||||||
defaultSize = (defaultSizeDip * density).roundToInt(),
|
defaultSize = (defaultSizeDip * density).roundToInt(),
|
||||||
result,
|
result,
|
||||||
).fetch()
|
).fetch()
|
||||||
|
@ -85,7 +87,7 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
private fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val page = call.argument<Int>("page")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val sampleSize = call.argument<Int>("sampleSize")
|
val sampleSize = call.argument<Int>("sampleSize")
|
||||||
val x = call.argument<Int>("regionX")
|
val x = call.argument<Int>("regionX")
|
||||||
val y = call.argument<Int>("regionY")
|
val y = call.argument<Int>("regionY")
|
||||||
|
@ -102,43 +104,49 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
||||||
val regionRect = Rect(x, y, x + width, y + height)
|
val regionRect = Rect(x, y, x + width, y + height)
|
||||||
when (mimeType) {
|
when (mimeType) {
|
||||||
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
MimeTypes.TIFF -> TiffRegionFetcher(activity).fetch(
|
||||||
uri,
|
uri = uri,
|
||||||
sampleSize,
|
page = pageId ?: 0,
|
||||||
regionRect,
|
sampleSize = sampleSize,
|
||||||
page = page ?: 0,
|
regionRect = regionRect,
|
||||||
result,
|
result = result,
|
||||||
)
|
)
|
||||||
else -> regionFetcher.fetch(
|
else -> regionFetcher.fetch(
|
||||||
uri,
|
uri = uri,
|
||||||
mimeType,
|
mimeType = mimeType,
|
||||||
sampleSize,
|
pageId = pageId,
|
||||||
regionRect,
|
sampleSize = sampleSize,
|
||||||
Size(imageWidth, imageHeight),
|
regionRect = regionRect,
|
||||||
result,
|
imageSize = Size(imageWidth, imageHeight),
|
||||||
|
result = result,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getImageEntry-args", "failed because of missing arguments", null)
|
result.error("getEntry-args", "failed because of missing arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val provider = getProvider(uri)
|
val provider = getProvider(uri)
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
result.error("getImageEntry-provider", "failed to find provider for uri=$uri", null)
|
result.error("getEntry-provider", "failed to find provider for uri=$uri", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
|
||||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||||
override fun onFailure(throwable: Throwable) = result.error("getImageEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
override fun onFailure(throwable: Throwable) = result.error("getEntry-failure", "failed to get entry for uri=$uri", throwable.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
Glide.get(activity).clearDiskCache()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val entryMap = call.argument<FieldMap>("entry")
|
val entryMap = call.argument<FieldMap>("entry")
|
||||||
val newName = call.argument<String>("newName")
|
val newName = call.argument<String>("newName")
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
|
@ -18,6 +25,7 @@ import com.drew.metadata.iptc.IptcDirectory
|
||||||
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
|
||||||
import com.drew.metadata.webp.WebpDirectory
|
import com.drew.metadata.webp.WebpDirectory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.*
|
import deckers.thibault.aves.metadata.*
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.describeAll
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
|
@ -38,13 +46,14 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
|
||||||
import deckers.thibault.aves.metadata.XMP.isPanorama
|
import deckers.thibault.aves.metadata.XMP.isPanorama
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.FileImageProvider
|
import deckers.thibault.aves.model.provider.FileImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.utils.BitmapUtils
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
import deckers.thibault.aves.utils.MimeTypes.isMultimedia
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
|
||||||
|
@ -66,14 +75,15 @@ import kotlin.math.roundToLong
|
||||||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) }
|
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
|
||||||
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) }
|
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
|
||||||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) }
|
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { getMultiPageInfo(call, Coresult(result)) }
|
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { getPanoramaInfo(call, Coresult(result)) }
|
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||||
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) }
|
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) }
|
"getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
|
||||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) }
|
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
|
||||||
|
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -430,7 +440,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType == MimeTypes.HEIC || mimeType == MimeTypes.HEIF) {
|
if (isHeifLike(mimeType)) {
|
||||||
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS) {
|
||||||
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
if (it > 1) flags = flags or MASK_IS_MULTIPAGE
|
||||||
}
|
}
|
||||||
|
@ -521,21 +531,57 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val pages = HashMap<Int, Any>()
|
val pages = ArrayList<Map<String, Any>>()
|
||||||
if (mimeType == MimeTypes.TIFF) {
|
if (mimeType == MimeTypes.TIFF) {
|
||||||
fun toMap(options: TiffBitmapFactory.Options): Map<String, Any> {
|
fun toMap(page: Int, options: TiffBitmapFactory.Options): HashMap<String, Any> {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"width" to options.outWidth,
|
KEY_PAGE to page,
|
||||||
"height" to options.outHeight,
|
KEY_MIME_TYPE to mimeType,
|
||||||
|
KEY_WIDTH to options.outWidth,
|
||||||
|
KEY_HEIGHT to options.outHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
getTiffPageInfo(uri, 0)?.let { first ->
|
getTiffPageInfo(uri, 0)?.let { first ->
|
||||||
pages[0] = toMap(first)
|
pages.add(toMap(0, first))
|
||||||
val pageCount = first.outDirectoryCount
|
val pageCount = first.outDirectoryCount
|
||||||
for (i in 1 until pageCount) {
|
for (i in 1 until pageCount) {
|
||||||
getTiffPageInfo(uri, i)?.let { pages[i] = toMap(it) }
|
getTiffPageInfo(uri, i)?.let { pages.add(toMap(i, it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (isHeifLike(mimeType)) {
|
||||||
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getInteger(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getLong(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
extractor.setDataSource(context, uri, null)
|
||||||
|
for (i in 0 until extractor.trackCount) {
|
||||||
|
try {
|
||||||
|
val format = extractor.getTrackFormat(i)
|
||||||
|
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||||
|
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
|
||||||
|
val page = hashMapOf<String, Any>(
|
||||||
|
KEY_PAGE to i,
|
||||||
|
KEY_MIME_TYPE to trackMime,
|
||||||
|
)
|
||||||
|
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { page[KEY_IS_DEFAULT] = it != 0 }
|
||||||
|
format.getSafeInt(MediaFormat.KEY_TRACK_ID) { page[KEY_TRACK_ID] = it }
|
||||||
|
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||||
|
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||||
|
if (isVideo(trackMime)) {
|
||||||
|
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||||
|
}
|
||||||
|
pages.add(page)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get track information for uri=$uri, track num=$i", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extractor.release()
|
||||||
}
|
}
|
||||||
result.success(pages)
|
result.success(pages)
|
||||||
}
|
}
|
||||||
|
@ -555,14 +601,16 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val metadata = ImageMetadataReader.readMetadata(input)
|
val metadata = ImageMetadataReader.readMetadata(input)
|
||||||
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java)
|
||||||
try {
|
try {
|
||||||
fun getProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
fun getIntProp(propName: String): Int? = xmpDirs.map { it.xmpMeta.getPropertyInteger(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||||
|
fun getStringProp(propName: String): String? = xmpDirs.map { it.xmpMeta.getPropertyString(XMP.GPANO_SCHEMA_NS, propName) }.firstOrNull { it != null }
|
||||||
val fields: FieldMap = hashMapOf(
|
val fields: FieldMap = hashMapOf(
|
||||||
"croppedAreaLeft" to getProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
|
"croppedAreaLeft" to getIntProp(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME),
|
||||||
"croppedAreaTop" to getProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
|
"croppedAreaTop" to getIntProp(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME),
|
||||||
"croppedAreaWidth" to getProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
|
"croppedAreaWidth" to getIntProp(XMP.GPANO_CROPPED_AREA_WIDTH_PROP_NAME),
|
||||||
"croppedAreaHeight" to getProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
|
"croppedAreaHeight" to getIntProp(XMP.GPANO_CROPPED_AREA_HEIGHT_PROP_NAME),
|
||||||
"fullPanoWidth" to getProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
|
"fullPanoWidth" to getIntProp(XMP.GPANO_FULL_PANO_WIDTH_PROP_NAME),
|
||||||
"fullPanoHeight" to getProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
|
"fullPanoHeight" to getIntProp(XMP.GPANO_FULL_PANO_HEIGHT_PROP_NAME),
|
||||||
|
"projectionType" to (getStringProp(XMP.GPANO_PROJECTION_TYPE_PROP_NAME) ?: XMP.GPANO_PROJECTION_TYPE_DEFAULT),
|
||||||
)
|
)
|
||||||
result.success(fields)
|
result.success(fields)
|
||||||
return
|
return
|
||||||
|
@ -580,6 +628,55 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
|
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val mimeType = call.argument<String>("mimeType")
|
||||||
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
|
val prop = call.argument<String>("prop")
|
||||||
|
if (mimeType == null || uri == null || prop == null) {
|
||||||
|
result.error("getContentResolverProp-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentUri: Uri = uri
|
||||||
|
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||||
|
try {
|
||||||
|
val id = ContentUris.parseId(uri)
|
||||||
|
contentUri = when {
|
||||||
|
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||||
|
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||||
|
else -> uri
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
contentUri = MediaStore.setRequireOriginal(contentUri)
|
||||||
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val projection = arrayOf(prop)
|
||||||
|
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
var value: Any? = null
|
||||||
|
try {
|
||||||
|
value = when (cursor.getType(0)) {
|
||||||
|
Cursor.FIELD_TYPE_NULL -> null
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
|
||||||
|
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
|
||||||
|
Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
|
||||||
|
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get value for key=$prop", e)
|
||||||
|
}
|
||||||
|
cursor.close()
|
||||||
|
result.success(value?.toString())
|
||||||
|
} else {
|
||||||
|
result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
|
@ -619,7 +716,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||||
exif.thumbnailBitmap?.let { bitmap ->
|
exif.thumbnailBitmap?.let { bitmap ->
|
||||||
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
TransformationUtils.rotateImageExif(BitmapUtils.getBitmapPool(context), bitmap, orientation)?.let {
|
||||||
it.getBytes(canHaveAlpha = false, recycle = false)?.let { thumbnails.add(it) }
|
it.getBytes(canHaveAlpha = false, recycle = false)?.let { bytes -> thumbnails.add(bytes) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -733,7 +830,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/metadata"
|
const val CHANNEL = "deckers.thibault/aves/metadata"
|
||||||
|
|
||||||
// catalog metadata
|
// catalog metadata & page info
|
||||||
private const val KEY_MIME_TYPE = "mimeType"
|
private const val KEY_MIME_TYPE = "mimeType"
|
||||||
private const val KEY_DATE_MILLIS = "dateMillis"
|
private const val KEY_DATE_MILLIS = "dateMillis"
|
||||||
private const val KEY_FLAGS = "flags"
|
private const val KEY_FLAGS = "flags"
|
||||||
|
@ -742,6 +839,12 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||||
private const val KEY_LONGITUDE = "longitude"
|
private const val KEY_LONGITUDE = "longitude"
|
||||||
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
|
||||||
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
|
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
|
||||||
|
private const val KEY_HEIGHT = "height"
|
||||||
|
private const val KEY_WIDTH = "width"
|
||||||
|
private const val KEY_PAGE = "page"
|
||||||
|
private const val KEY_TRACK_ID = "trackId"
|
||||||
|
private const val KEY_IS_DEFAULT = "isDefault"
|
||||||
|
private const val KEY_DURATION = "durationMillis"
|
||||||
|
|
||||||
private const val MASK_IS_ANIMATED = 1 shl 0
|
private const val MASK_IS_ANIMATED = 1 shl 0
|
||||||
private const val MASK_IS_FLIPPED = 1 shl 1
|
private const val MASK_IS_FLIPPED = 1 shl 1
|
||||||
|
|
|
@ -5,7 +5,7 @@ import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import androidx.annotation.RequiresApi
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
@ -20,27 +20,18 @@ import java.util.*
|
||||||
class StorageHandler(private val context: Context) : MethodCallHandler {
|
class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getStorageVolumes" -> {
|
"getStorageVolumes" -> safe(call, result, ::getStorageVolumes)
|
||||||
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
|
||||||
storageVolumes
|
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
|
||||||
} else {
|
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||||
// TODO TLAD find alternative for Android <N
|
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||||
emptyList()
|
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||||
}
|
|
||||||
result.success(volumes)
|
|
||||||
}
|
|
||||||
"getFreeSpace" -> getFreeSpace(call, result)
|
|
||||||
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
|
||||||
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
|
|
||||||
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
|
|
||||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) }
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storageVolumes: List<Map<String, Any>>
|
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
get() {
|
|
||||||
val volumes = ArrayList<Map<String, Any>>()
|
val volumes = ArrayList<Map<String, Any>>()
|
||||||
val sm = context.getSystemService(StorageManager::class.java)
|
val sm = context.getSystemService(StorageManager::class.java)
|
||||||
if (sm != null) {
|
if (sm != null) {
|
||||||
|
@ -61,7 +52,12 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return volumes
|
volumes
|
||||||
|
} else {
|
||||||
|
// TODO TLAD find alternative for Android <N
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
result.success(volumes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
|
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -93,6 +89,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getGrantedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
|
||||||
|
}
|
||||||
|
|
||||||
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
|
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val dirPaths = call.argument<List<String>>("dirPaths")
|
val dirPaths = call.argument<List<String>>("dirPaths")
|
||||||
if (dirPaths == null) {
|
if (dirPaths == null) {
|
||||||
|
|
|
@ -1,15 +1,22 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class RegionFetcher internal constructor(
|
class RegionFetcher internal constructor(
|
||||||
|
@ -17,21 +24,42 @@ class RegionFetcher internal constructor(
|
||||||
) {
|
) {
|
||||||
private var lastDecoderRef: LastDecoderRef? = null
|
private var lastDecoderRef: LastDecoderRef? = null
|
||||||
|
|
||||||
|
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
||||||
|
|
||||||
|
private val multiTrackGlideOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
fun fetch(
|
fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
|
pageId: Int?,
|
||||||
sampleSize: Int,
|
sampleSize: Int,
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
imageSize: Size,
|
imageSize: Size,
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
|
if (MimeTypes.isHeifLike(mimeType) && pageId != null) {
|
||||||
|
val id = Pair(uri, pageId)
|
||||||
|
fetch(
|
||||||
|
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, pageId) },
|
||||||
|
mimeType = MimeTypes.JPEG,
|
||||||
|
pageId = null,
|
||||||
|
sampleSize = sampleSize,
|
||||||
|
regionRect = regionRect,
|
||||||
|
imageSize = imageSize,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val options = BitmapFactory.Options().apply {
|
val options = BitmapFactory.Options().apply {
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentDecoderRef = lastDecoderRef
|
var currentDecoderRef = lastDecoderRef
|
||||||
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
||||||
currentDecoderRef.decoder.recycle()
|
|
||||||
currentDecoderRef = null
|
currentDecoderRef = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,6 +102,26 @@ class RegionFetcher internal constructor(
|
||||||
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
result.error("getRegion-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createJpegForPage(sourceUri: Uri, pageId: Int): Uri {
|
||||||
|
val target = Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.apply(multiTrackGlideOptions)
|
||||||
|
.load(MultiTrackImage(context, sourceUri, pageId))
|
||||||
|
.submit()
|
||||||
|
try {
|
||||||
|
val bitmap = target.get()
|
||||||
|
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
||||||
|
deleteOnExit()
|
||||||
|
outputStream().use { outputStream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uri.fromFile(tempFile)
|
||||||
|
} finally {
|
||||||
|
Glide.with(context).clear(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LastDecoderRef(
|
private data class LastDecoderRef(
|
|
@ -1,4 +1,4 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -13,11 +13,13 @@ import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
import deckers.thibault.aves.decoder.TiffThumbnail
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
|
@ -32,14 +34,16 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val isFlipped: Boolean,
|
private val isFlipped: Boolean,
|
||||||
width: Int?,
|
width: Int?,
|
||||||
height: Int?,
|
height: Int?,
|
||||||
page: Int?,
|
private val pageId: Int?,
|
||||||
private val defaultSize: Int,
|
private val defaultSize: Int,
|
||||||
private val result: MethodChannel.Result,
|
private val result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
private val uri: Uri = Uri.parse(uri)
|
private val uri: Uri = Uri.parse(uri)
|
||||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val page = page ?: 0
|
private val tiffFetch = mimeType == MimeTypes.TIFF
|
||||||
|
private val multiTrackFetch = isHeifLike(mimeType) && pageId != null
|
||||||
|
private val customFetch = tiffFetch || multiTrackFetch
|
||||||
|
|
||||||
fun fetch() {
|
fun fetch() {
|
||||||
var bitmap: Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
|
@ -47,7 +51,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
var exception: Exception? = null
|
var exception: Exception? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mimeType != MimeTypes.TIFF && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
if (!customFetch && (width == defaultSize || height == defaultSize) && !isFlipped) {
|
||||||
// Fetch low quality thumbnails when size is not specified.
|
// Fetch low quality thumbnails when size is not specified.
|
||||||
// As of Android R, the Media Store content resolver may return a thumbnail
|
// As of Android R, the Media Store content resolver may return a thumbnail
|
||||||
// that is automatically rotated according to EXIF orientation, but not flipped,
|
// that is automatically rotated according to EXIF orientation, but not flipped,
|
||||||
|
@ -110,7 +114,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
// add signature to ignore cache for images which got modified but kept the same URI
|
// add signature to ignore cache for images which got modified but kept the same URI
|
||||||
var options = RequestOptions()
|
var options = RequestOptions()
|
||||||
.format(DecodeFormat.PREFER_RGB_565)
|
.format(DecodeFormat.PREFER_RGB_565)
|
||||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$page"))
|
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||||
.override(width, height)
|
.override(width, height)
|
||||||
|
|
||||||
val target = if (isVideo(mimeType)) {
|
val target = if (isVideo(mimeType)) {
|
||||||
|
@ -121,7 +125,11 @@ class ThumbnailFetcher internal constructor(
|
||||||
.load(VideoThumbnail(context, uri))
|
.load(VideoThumbnail(context, uri))
|
||||||
.submit(width, height)
|
.submit(width, height)
|
||||||
} else {
|
} else {
|
||||||
val model: Any = if (mimeType == MimeTypes.TIFF) TiffThumbnail(context, uri, page) else uri
|
val model: Any = when {
|
||||||
|
tiffFetch -> TiffImage(context, uri, pageId)
|
||||||
|
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||||
|
else -> uri
|
||||||
|
}
|
||||||
Glide.with(context)
|
Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
|
@ -1,4 +1,4 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls.fetchers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
@ -13,9 +13,9 @@ class TiffRegionFetcher internal constructor(
|
||||||
) {
|
) {
|
||||||
fun fetch(
|
fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
|
page: Int,
|
||||||
sampleSize: Int,
|
sampleSize: Int,
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
page: Int = 0,
|
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
|
@ -0,0 +1,61 @@
|
||||||
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
|
|
||||||
|
class ContentChangeStreamHandler(private val context: Context) : EventChannel.StreamHandler {
|
||||||
|
private val contentObserver = object : ContentObserver(null) {
|
||||||
|
override fun onChange(selfChange: Boolean) {
|
||||||
|
this.onChange(selfChange, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
// warning: querying the content resolver right after a change
|
||||||
|
// sometimes yields obsolete results
|
||||||
|
success(uri?.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private lateinit var eventSink: EventSink
|
||||||
|
private lateinit var handler: Handler
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.contentResolver.apply {
|
||||||
|
registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||||
|
registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, contentObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||||
|
this.eventSink = eventSink
|
||||||
|
handler = Handler(Looper.getMainLooper())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {}
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
context.contentResolver.unregisterContentObserver(contentObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun success(uri: String?) {
|
||||||
|
handler.post {
|
||||||
|
try {
|
||||||
|
eventSink.success(uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to use event sink", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(ContentChangeStreamHandler::class.java)
|
||||||
|
const val CHANNEL = "deckers.thibault/aves/contentchange"
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,11 +9,14 @@ import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
import deckers.thibault.aves.decoder.VideoThumbnail
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isHeifLike
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
|
@ -23,7 +26,6 @@ import io.flutter.plugin.common.EventChannel.EventSink
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
@ -84,7 +86,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
||||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||||
val isFlipped = arguments["isFlipped"] as Boolean
|
val isFlipped = arguments["isFlipped"] as Boolean
|
||||||
val page = arguments["page"] as Int
|
val pageId = arguments["pageId"] as Int?
|
||||||
|
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
error("streamImage-args", "failed because of missing arguments", null)
|
error("streamImage-args", "failed because of missing arguments", null)
|
||||||
|
@ -94,11 +96,9 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
|
|
||||||
if (isVideo(mimeType)) {
|
if (isVideo(mimeType)) {
|
||||||
streamVideoByGlide(uri)
|
streamVideoByGlide(uri)
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
|
||||||
streamTiffImage(uri, page)
|
|
||||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||||
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
|
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
|
||||||
} else {
|
} else {
|
||||||
// to be decoded by Flutter
|
// to be decoded by Flutter
|
||||||
streamImageAsIs(uri)
|
streamImageAsIs(uri)
|
||||||
|
@ -114,11 +114,19 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
private fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
|
||||||
|
val model: Any = if (isHeifLike(mimeType) && pageId != null) {
|
||||||
|
MultiTrackImage(activity, uri, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(activity, uri, pageId)
|
||||||
|
} else {
|
||||||
|
uri
|
||||||
|
}
|
||||||
|
|
||||||
val target = Glide.with(activity)
|
val target = Glide.with(activity)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(glideOptions)
|
||||||
.load(uri)
|
.load(model)
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
|
@ -157,28 +165,6 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun streamTiffImage(uri: Uri, page: Int = 0) {
|
|
||||||
val resolver = activity.contentResolver
|
|
||||||
try {
|
|
||||||
val fd = resolver.openFileDescriptor(uri, "r")?.detachFd()
|
|
||||||
if (fd == null) {
|
|
||||||
error("streamImage-tiff-fd", "failed to get file descriptor for uri=$uri", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val options = TiffBitmapFactory.Options().apply {
|
|
||||||
inDirectoryNumber = page
|
|
||||||
}
|
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
|
||||||
if (bitmap != null) {
|
|
||||||
success(bitmap.getBytes(canHaveAlpha = true, recycle = true))
|
|
||||||
} else {
|
|
||||||
error("streamImage-tiff-null", "failed to get tiff image (dir=$page) from uri=$uri", null)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
error("streamImage-tiff-exception", "failed to get image from uri=$uri", toErrorDetails(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toErrorDetails(e: Exception): String? {
|
private fun toErrorDetails(e: Exception): String? {
|
||||||
val errorDetails = e.message
|
val errorDetails = e.message
|
||||||
return if (errorDetails?.isNotEmpty() == true) {
|
return if (errorDetails?.isNotEmpty() == true) {
|
||||||
|
|
|
@ -5,8 +5,8 @@ import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.model.AvesImageEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -42,6 +42,7 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
|
|
||||||
when (op) {
|
when (op) {
|
||||||
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
||||||
|
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
|
||||||
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
||||||
else -> endOfStream()
|
else -> endOfStream()
|
||||||
}
|
}
|
||||||
|
@ -80,36 +81,6 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun move() {
|
|
||||||
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
|
||||||
endOfStream()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// assume same provider for all entries
|
|
||||||
val firstEntry = entryMapList.first()
|
|
||||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
|
||||||
if (provider == null) {
|
|
||||||
error("move-provider", "failed to find provider for entry=$firstEntry", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val copy = arguments["copy"] as Boolean?
|
|
||||||
var destinationDir = arguments["destinationPath"] as String?
|
|
||||||
if (copy == null || destinationDir == null) {
|
|
||||||
error("move-args", "failed because of missing arguments", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
|
||||||
val entries = entryMapList.map(::AvesImageEntry)
|
|
||||||
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
|
|
||||||
override fun onSuccess(fields: FieldMap) = success(fields)
|
|
||||||
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
|
||||||
})
|
|
||||||
endOfStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun delete() {
|
private suspend fun delete() {
|
||||||
if (entryMapList.isEmpty()) {
|
if (entryMapList.isEmpty()) {
|
||||||
endOfStream()
|
endOfStream()
|
||||||
|
@ -144,6 +115,66 @@ class ImageOpStreamHandler(private val context: Context, private val arguments:
|
||||||
endOfStream()
|
endOfStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun export() {
|
||||||
|
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||||
|
endOfStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationDir = arguments["destinationPath"] as String?
|
||||||
|
val mimeType = arguments["mimeType"] as String?
|
||||||
|
if (destinationDir == null || mimeType == null) {
|
||||||
|
error("export-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume same provider for all entries
|
||||||
|
val firstEntry = entryMapList.first()
|
||||||
|
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||||
|
if (provider == null) {
|
||||||
|
error("export-provider", "failed to find provider for entry=$firstEntry", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
|
provider.exportMultiple(context, mimeType, destinationDir, entries, object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
|
||||||
|
})
|
||||||
|
endOfStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun move() {
|
||||||
|
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
|
||||||
|
endOfStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val copy = arguments["copy"] as Boolean?
|
||||||
|
var destinationDir = arguments["destinationPath"] as String?
|
||||||
|
if (copy == null || destinationDir == null) {
|
||||||
|
error("move-args", "failed because of missing arguments", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume same provider for all entries
|
||||||
|
val firstEntry = entryMapList.first()
|
||||||
|
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
|
||||||
|
if (provider == null) {
|
||||||
|
error("move-provider", "failed to find provider for entry=$firstEntry", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||||
|
val entries = entryMapList.map(::AvesEntry)
|
||||||
|
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
|
||||||
|
override fun onSuccess(fields: FieldMap) = success(fields)
|
||||||
|
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||||
|
})
|
||||||
|
endOfStream()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
private val LOG_TAG = LogUtils.createTag(ImageOpStreamHandler::class.java)
|
||||||
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
const val CHANNEL = "deckers.thibault/aves/imageopstream"
|
||||||
|
|
|
@ -18,4 +18,8 @@ class IntentStreamHandler : EventChannel.StreamHandler {
|
||||||
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
||||||
eventSink?.success(intentData)
|
eventSink?.success(intentData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL = "deckers.thibault/aves/intent"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.Priority
|
||||||
|
import com.bumptech.glide.Registry
|
||||||
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.load.DataSource
|
||||||
|
import com.bumptech.glide.load.Options
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
||||||
|
import com.bumptech.glide.load.model.ModelLoader
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
|
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
|
import com.bumptech.glide.module.LibraryGlideModule
|
||||||
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import deckers.thibault.aves.metadata.MultiTrackMedia
|
||||||
|
|
||||||
|
|
||||||
|
@GlideModule
|
||||||
|
class MultiTrackImageGlideModule : LibraryGlideModule() {
|
||||||
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
|
registry.append(MultiTrackImage::class.java, Bitmap::class.java, MultiTrackThumbnailLoader.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiTrackImage(val context: Context, val uri: Uri, val trackId: Int?)
|
||||||
|
|
||||||
|
internal class MultiTrackThumbnailLoader : ModelLoader<MultiTrackImage, Bitmap> {
|
||||||
|
override fun buildLoadData(model: MultiTrackImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||||
|
return ModelLoader.LoadData(ObjectKey(model.uri), MultiTrackImageFetcher(model, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(model: MultiTrackImage): Boolean = true
|
||||||
|
|
||||||
|
internal class Factory : ModelLoaderFactory<MultiTrackImage, Bitmap> {
|
||||||
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MultiTrackImage, Bitmap> = MultiTrackThumbnailLoader()
|
||||||
|
|
||||||
|
override fun teardown() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class MultiTrackImageFetcher(val model: MultiTrackImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||||
|
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||||
|
callback.onLoadFailed(Exception("unsupported Android version"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = model.context
|
||||||
|
val uri = model.uri
|
||||||
|
val trackId = model.trackId
|
||||||
|
|
||||||
|
val bitmap = MultiTrackMedia.getImage(context, uri, trackId)
|
||||||
|
if (bitmap == null) {
|
||||||
|
callback.onLoadFailed(Exception("null bitmap"))
|
||||||
|
} else {
|
||||||
|
callback.onDataReady(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cleanup() {}
|
||||||
|
|
||||||
|
// cannot cancel
|
||||||
|
override fun cancel() {}
|
||||||
|
|
||||||
|
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||||
|
|
||||||
|
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||||
|
}
|
|
@ -0,0 +1,99 @@
|
||||||
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.Priority
|
||||||
|
import com.bumptech.glide.Registry
|
||||||
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.load.DataSource
|
||||||
|
import com.bumptech.glide.load.Options
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher
|
||||||
|
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
||||||
|
import com.bumptech.glide.load.model.ModelLoader
|
||||||
|
import com.bumptech.glide.load.model.ModelLoaderFactory
|
||||||
|
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
||||||
|
import com.bumptech.glide.module.LibraryGlideModule
|
||||||
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
|
|
||||||
|
@GlideModule
|
||||||
|
class TiffGlideModule : LibraryGlideModule() {
|
||||||
|
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
||||||
|
registry.append(TiffImage::class.java, Bitmap::class.java, TiffLoader.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TiffImage(val context: Context, val uri: Uri, val page: Int?)
|
||||||
|
|
||||||
|
internal class TiffLoader : ModelLoader<TiffImage, Bitmap> {
|
||||||
|
override fun buildLoadData(model: TiffImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
|
||||||
|
return ModelLoader.LoadData(ObjectKey(model.uri), TiffFetcher(model, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(model: TiffImage): Boolean = true
|
||||||
|
|
||||||
|
internal class Factory : ModelLoaderFactory<TiffImage, Bitmap> {
|
||||||
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffImage, Bitmap> = TiffLoader()
|
||||||
|
|
||||||
|
override fun teardown() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
|
||||||
|
override fun loadData(priority: Priority, callback: DataCallback<in Bitmap>) {
|
||||||
|
val context = model.context
|
||||||
|
val uri = model.uri
|
||||||
|
val page = model.page ?: 0
|
||||||
|
|
||||||
|
var sampleSize = 1
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
// determine sample size
|
||||||
|
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
|
if (fd == null) {
|
||||||
|
callback.onLoadFailed(Exception("null file descriptor"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val options = TiffBitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
inDirectoryNumber = page
|
||||||
|
}
|
||||||
|
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
val imageWidth = options.outWidth
|
||||||
|
val imageHeight = options.outHeight
|
||||||
|
if (imageHeight > height || imageWidth > width) {
|
||||||
|
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
||||||
|
sampleSize *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode
|
||||||
|
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
||||||
|
if (fd == null) {
|
||||||
|
callback.onLoadFailed(Exception("null file descriptor"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val options = TiffBitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = false
|
||||||
|
inDirectoryNumber = page
|
||||||
|
inSampleSize = sampleSize
|
||||||
|
}
|
||||||
|
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||||
|
if (bitmap == null) {
|
||||||
|
callback.onLoadFailed(Exception("null bitmap"))
|
||||||
|
} else {
|
||||||
|
callback.onDataReady(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cleanup() {}
|
||||||
|
|
||||||
|
// cannot cancel
|
||||||
|
override fun cancel() {}
|
||||||
|
|
||||||
|
override fun getDataClass(): Class<Bitmap> = Bitmap::class.java
|
||||||
|
|
||||||
|
override fun getDataSource(): DataSource = DataSource.LOCAL
|
||||||
|
}
|
|
@ -1,99 +0,0 @@
|
||||||
package deckers.thibault.aves.decoder
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.Priority
|
|
||||||
import com.bumptech.glide.Registry
|
|
||||||
import com.bumptech.glide.annotation.GlideModule
|
|
||||||
import com.bumptech.glide.load.DataSource
|
|
||||||
import com.bumptech.glide.load.Options
|
|
||||||
import com.bumptech.glide.load.data.DataFetcher
|
|
||||||
import com.bumptech.glide.load.data.DataFetcher.DataCallback
|
|
||||||
import com.bumptech.glide.load.model.ModelLoader
|
|
||||||
import com.bumptech.glide.load.model.ModelLoaderFactory
|
|
||||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
|
||||||
import com.bumptech.glide.module.LibraryGlideModule
|
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
@GlideModule
|
|
||||||
class TiffThumbnailGlideModule : LibraryGlideModule() {
|
|
||||||
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
|
|
||||||
registry.append(TiffThumbnail::class.java, InputStream::class.java, TiffThumbnailLoader.Factory())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TiffThumbnail(val context: Context, val uri: Uri, val page: Int)
|
|
||||||
|
|
||||||
internal class TiffThumbnailLoader : ModelLoader<TiffThumbnail, InputStream> {
|
|
||||||
override fun buildLoadData(model: TiffThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
|
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), TiffThumbnailFetcher(model, width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handles(tiffThumbnail: TiffThumbnail): Boolean = true
|
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<TiffThumbnail, InputStream> {
|
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<TiffThumbnail, InputStream> = TiffThumbnailLoader()
|
|
||||||
|
|
||||||
override fun teardown() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class TiffThumbnailFetcher(val model: TiffThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
|
|
||||||
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
|
|
||||||
val context = model.context
|
|
||||||
val uri = model.uri
|
|
||||||
val page = model.page
|
|
||||||
|
|
||||||
// determine sample size
|
|
||||||
var fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
|
||||||
if (fd == null) {
|
|
||||||
callback.onLoadFailed(Exception("null file descriptor"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var sampleSize = 1
|
|
||||||
var options = TiffBitmapFactory.Options().apply {
|
|
||||||
inJustDecodeBounds = true
|
|
||||||
inDirectoryNumber = page
|
|
||||||
}
|
|
||||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
|
||||||
val imageWidth = options.outWidth
|
|
||||||
val imageHeight = options.outHeight
|
|
||||||
if (imageHeight > height || imageWidth > width) {
|
|
||||||
while (imageHeight / (sampleSize * 2) > height && imageWidth / (sampleSize * 2) > width) {
|
|
||||||
sampleSize *= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// decode
|
|
||||||
fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd()
|
|
||||||
if (fd == null) {
|
|
||||||
callback.onLoadFailed(Exception("null file descriptor"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
options = TiffBitmapFactory.Options().apply {
|
|
||||||
inJustDecodeBounds = false
|
|
||||||
inDirectoryNumber = page
|
|
||||||
inSampleSize = sampleSize
|
|
||||||
}
|
|
||||||
val bitmap = TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
|
||||||
if (bitmap == null) {
|
|
||||||
callback.onLoadFailed(Exception("null bitmap"))
|
|
||||||
} else {
|
|
||||||
callback.onDataReady(bitmap.getBytes()?.inputStream())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
|
|
||||||
override fun cleanup() {}
|
|
||||||
|
|
||||||
// cannot cancel
|
|
||||||
override fun cancel() {}
|
|
||||||
|
|
||||||
override fun getDataClass(): Class<InputStream> = InputStream::class.java
|
|
||||||
|
|
||||||
override fun getDataSource(): DataSource = DataSource.LOCAL
|
|
||||||
}
|
|
|
@ -1,7 +1,9 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
|
@ -34,7 +36,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
|
||||||
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
|
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(videoThumbnail: VideoThumbnail): Boolean = true
|
override fun handles(model: VideoThumbnail): Boolean = true
|
||||||
|
|
||||||
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
|
||||||
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
|
||||||
|
@ -48,9 +50,29 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
|
||||||
val retriever = openMetadataRetriever(model.context, model.uri)
|
val retriever = openMetadataRetriever(model.context, model.uri)
|
||||||
if (retriever != null) {
|
if (retriever != null) {
|
||||||
try {
|
try {
|
||||||
val picture = retriever.embeddedPicture ?: retriever.frameAtTime?.getBytes(canHaveAlpha = false, recycle = false)
|
var bytes = retriever.embeddedPicture
|
||||||
if (picture != null) {
|
if (bytes == null) {
|
||||||
callback.onDataReady(ByteArrayInputStream(picture))
|
// try to match the thumbnails returned by the content resolver / Media Store
|
||||||
|
// the following strategies are from empirical evidence from a few test devices:
|
||||||
|
// - API 29: sync frame closest to the middle
|
||||||
|
// - API 26/27: default representative frame at any time position
|
||||||
|
var timeMillis: Long? = null
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
|
||||||
|
if (durationMillis != null) {
|
||||||
|
timeMillis = durationMillis / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val frame = if (timeMillis != null) {
|
||||||
|
retriever.getFrameAtTime(timeMillis * 1000)
|
||||||
|
} else {
|
||||||
|
retriever.frameAtTime
|
||||||
|
}
|
||||||
|
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes != null) {
|
||||||
|
callback.onDataReady(ByteArrayInputStream(bytes))
|
||||||
} else {
|
} else {
|
||||||
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
callback.onLoadFailed(Exception("failed to get embedded picture or any frame"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
|
|
||||||
|
object MultiTrackMedia {
|
||||||
|
private val LOG_TAG = LogUtils.createTag(MultiTrackMedia::class.java)
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.P)
|
||||||
|
fun getImage(context: Context, uri: Uri, trackId: Int?): Bitmap? {
|
||||||
|
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return null
|
||||||
|
try {
|
||||||
|
return if (trackId != null) {
|
||||||
|
val imageIndex = trackIdToImageIndex(context, uri, trackId) ?: return null
|
||||||
|
retriever.getImageAtIndex(imageIndex)
|
||||||
|
} else {
|
||||||
|
retriever.primaryImage
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to extract image from uri=$uri trackId=$trackId", e)
|
||||||
|
} finally {
|
||||||
|
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
|
||||||
|
retriever.release()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackIdToImageIndex(context: Context, uri: Uri, trackId: Int): Int? {
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
try {
|
||||||
|
extractor.setDataSource(context, uri, null)
|
||||||
|
val trackCount = extractor.trackCount
|
||||||
|
var imageIndex = 0
|
||||||
|
for (i in 0 until trackCount) {
|
||||||
|
val trackFormat = extractor.getTrackFormat(i)
|
||||||
|
if (trackId == trackFormat.getInteger(MediaFormat.KEY_TRACK_ID)) {
|
||||||
|
return imageIndex
|
||||||
|
}
|
||||||
|
if (MimeTypes.isImage(trackFormat.getString(MediaFormat.KEY_MIME))) {
|
||||||
|
imageIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get image index for uri=$uri, trackId=$trackId", e)
|
||||||
|
} finally {
|
||||||
|
extractor.release()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.adobe.internal.xmp.XMPError
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPMeta
|
import com.adobe.internal.xmp.XMPMeta
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -9,6 +10,8 @@ import java.util.*
|
||||||
object XMP {
|
object XMP {
|
||||||
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
private val LOG_TAG = LogUtils.createTag(XMP::class.java)
|
||||||
|
|
||||||
|
// standard namespaces
|
||||||
|
// cf com.adobe.internal.xmp.XMPConst
|
||||||
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
|
||||||
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
const val PHOTOSHOP_SCHEMA_NS = "http://ns.adobe.com/photoshop/1.0/"
|
||||||
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
|
||||||
|
@ -51,27 +54,46 @@ object XMP {
|
||||||
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
const val GPANO_CROPPED_AREA_TOP_PROP_NAME = "GPano:CroppedAreaTopPixels"
|
||||||
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
|
const val GPANO_FULL_PANO_HEIGHT_PROP_NAME = "GPano:FullPanoHeightPixels"
|
||||||
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
|
const val GPANO_FULL_PANO_WIDTH_PROP_NAME = "GPano:FullPanoWidthPixels"
|
||||||
private const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
const val GPANO_PROJECTION_TYPE_PROP_NAME = "GPano:ProjectionType"
|
||||||
|
const val GPANO_PROJECTION_TYPE_DEFAULT = "equirectangular"
|
||||||
|
|
||||||
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
|
private const val PMTM_IS_PANO360 = "pmtm:IsPano360"
|
||||||
|
|
||||||
|
// `GPano:ProjectionType` is required by spec but it is sometimes missing, assuming default
|
||||||
|
// `GPano:FullPanoHeightPixels` is required by spec but it is sometimes missing (e.g. Samsung Camera app panorama mode)
|
||||||
private val gpanoRequiredProps = listOf(
|
private val gpanoRequiredProps = listOf(
|
||||||
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
GPANO_CROPPED_AREA_HEIGHT_PROP_NAME,
|
||||||
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
GPANO_CROPPED_AREA_WIDTH_PROP_NAME,
|
||||||
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
GPANO_CROPPED_AREA_LEFT_PROP_NAME,
|
||||||
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
GPANO_CROPPED_AREA_TOP_PROP_NAME,
|
||||||
GPANO_FULL_PANO_HEIGHT_PROP_NAME,
|
|
||||||
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
GPANO_FULL_PANO_WIDTH_PROP_NAME,
|
||||||
GPANO_PROJECTION_TYPE_PROP_NAME,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// extensions
|
// extensions
|
||||||
|
|
||||||
fun XMPMeta.isPanorama(): Boolean {
|
fun XMPMeta.isPanorama(): Boolean {
|
||||||
// Google
|
// Google
|
||||||
|
try {
|
||||||
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
if (gpanoRequiredProps.all { doesPropertyExist(GPANO_SCHEMA_NS, it) }) return true
|
||||||
|
} catch (e: XMPException) {
|
||||||
|
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||||
|
// `BADSCHEMA` code is reported when we check a property
|
||||||
|
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||||
|
Log.w(LOG_TAG, "failed to check Google panorama props from XMP", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Photomatix
|
// Photomatix
|
||||||
|
try {
|
||||||
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
|
if (getPropertyString(PMTM_SCHEMA_NS, PMTM_IS_PANO360) == "Yes") return true
|
||||||
|
} catch (e: XMPException) {
|
||||||
|
if (e.errorCode != XMPError.BADSCHEMA) {
|
||||||
|
// `BADSCHEMA` code is reported when we check a property
|
||||||
|
// from a non standard namespace, and that namespace is not declared in the XMP
|
||||||
|
Log.w(LOG_TAG, "failed to check Photomatix panorama props from XMP", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,7 +124,7 @@ object XMP {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: XMPException) {
|
} catch (e: XMPException) {
|
||||||
Log.w(LOG_TAG, "failed to get text for XMP schema=$schema, propName=$propName", e)
|
Log.w(LOG_TAG, "failed to get date for XMP schema=$schema, propName=$propName", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
package deckers.thibault.aves.model
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
|
||||||
class AvesImageEntry(map: FieldMap) {
|
class AvesEntry(map: FieldMap) {
|
||||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||||
val path = map["path"] as String? // best effort to get local path
|
val path = map["path"] as String? // best effort to get local path
|
||||||
|
val pageId = map["pageId"] as Int? // null means the main entry
|
||||||
val mimeType = map["mimeType"] as String
|
val mimeType = map["mimeType"] as String
|
||||||
val width = map["width"] as Int
|
val width = map["width"] as Int
|
||||||
val height = map["height"] as Int
|
val height = map["height"] as Int
|
||||||
val rotationDegrees = map["rotationDegrees"] as Int
|
val rotationDegrees = map["rotationDegrees"] as Int
|
||||||
|
val isFlipped = map["isFlipped"] as Boolean
|
||||||
}
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
|
typealias FieldMap = MutableMap<String, Any?>
|
|
@ -25,13 +25,13 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
|
||||||
import deckers.thibault.aves.model.provider.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class SourceImageEntry {
|
class SourceEntry {
|
||||||
val uri: Uri // content or file URI
|
val uri: Uri // content or file URI
|
||||||
var path: String? = null // best effort to get local path
|
var path: String? = null // best effort to get local path
|
||||||
private val sourceMimeType: String
|
private val sourceMimeType: String
|
||||||
|
@ -119,7 +119,7 @@ class SourceImageEntry {
|
||||||
// metadata retrieval
|
// metadata retrieval
|
||||||
// expects entry with: uri, mimeType
|
// expects entry with: uri, mimeType
|
||||||
// finds: width, height, orientation/rotation, date, title, duration
|
// finds: width, height, orientation/rotation, date, title, duration
|
||||||
fun fillPreCatalogMetadata(context: Context): SourceImageEntry {
|
fun fillPreCatalogMetadata(context: Context): SourceEntry {
|
||||||
if (isSvg) return this
|
if (isSvg) return this
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
fillVideoByMediaMetadataRetriever(context)
|
fillVideoByMediaMetadataRetriever(context)
|
|
@ -3,7 +3,7 @@ package deckers.thibault.aves.model.provider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import deckers.thibault.aves.model.SourceImageEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
|
|
||||||
internal class ContentImageProvider : ImageProvider() {
|
internal class ContentImageProvider : ImageProvider() {
|
||||||
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
override suspend fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
|
||||||
|
@ -28,7 +28,7 @@ internal class ContentImageProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val entry = SourceImageEntry(map).fillPreCatalogMetadata(context)
|
val entry = SourceEntry(map).fillPreCatalogMetadata(context)
|
||||||
if (entry.isSized || entry.isSvg) {
|
if (entry.isSized || entry.isSvg) {
|
||||||
callback.onSuccess(entry.toMap())
|
callback.onSuccess(entry.toMap())
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,7 @@ package deckers.thibault.aves.model.provider
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import deckers.thibault.aves.model.SourceImageEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
internal class FileImageProvider : ImageProvider() {
|
internal class FileImageProvider : ImageProvider() {
|
||||||
|
@ -12,7 +12,7 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val entry = SourceImageEntry(uri, mimeType)
|
val entry = SourceEntry(uri, mimeType)
|
||||||
|
|
||||||
val path = uri.path
|
val path = uri.path
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
|
|
|
@ -2,18 +2,28 @@ package deckers.thibault.aves.model.provider
|
||||||
|
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.AvesImageEntry
|
import deckers.thibault.aves.decoder.MultiTrackImage
|
||||||
|
import deckers.thibault.aves.decoder.TiffImage
|
||||||
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
import deckers.thibault.aves.utils.BitmapUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
|
||||||
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
@ -32,10 +42,151 @@ abstract class ImageProvider {
|
||||||
throw UnsupportedOperationException()
|
throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
|
|
||||||
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesImageEntry>, callback: ImageOpCallback) {
|
open suspend fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
|
||||||
callback.onFailure(UnsupportedOperationException())
|
callback.onFailure(UnsupportedOperationException())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun exportMultiple(
|
||||||
|
context: Context,
|
||||||
|
mimeType: String,
|
||||||
|
destinationDir: String,
|
||||||
|
entries: List<AvesEntry>,
|
||||||
|
callback: ImageOpCallback,
|
||||||
|
) {
|
||||||
|
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||||
|
if (destinationDirDocFile == null) {
|
||||||
|
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entry in entries) {
|
||||||
|
val sourceUri = entry.uri
|
||||||
|
val sourcePath = entry.path
|
||||||
|
val pageId = entry.pageId
|
||||||
|
|
||||||
|
val result = hashMapOf<String, Any?>(
|
||||||
|
"uri" to sourceUri.toString(),
|
||||||
|
"pageId" to pageId,
|
||||||
|
"success" to false,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val newFields = exportSingleByTreeDocAndScan(
|
||||||
|
context = context,
|
||||||
|
sourceEntry = entry,
|
||||||
|
destinationDir = destinationDir,
|
||||||
|
destinationDirDocFile = destinationDirDocFile,
|
||||||
|
exportMimeType = mimeType,
|
||||||
|
)
|
||||||
|
result["newFields"] = newFields
|
||||||
|
result["success"] = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e)
|
||||||
|
}
|
||||||
|
callback.onSuccess(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun exportSingleByTreeDocAndScan(
|
||||||
|
context: Context,
|
||||||
|
sourceEntry: AvesEntry,
|
||||||
|
destinationDir: String,
|
||||||
|
destinationDirDocFile: DocumentFileCompat,
|
||||||
|
exportMimeType: String,
|
||||||
|
): FieldMap {
|
||||||
|
val sourceMimeType = sourceEntry.mimeType
|
||||||
|
val sourceUri = sourceEntry.uri
|
||||||
|
val pageId = sourceEntry.pageId
|
||||||
|
|
||||||
|
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
||||||
|
val sourcePath = sourceEntry.path
|
||||||
|
val sourceFile = File(sourcePath)
|
||||||
|
val sourceFileName = sourceFile.name
|
||||||
|
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||||
|
} else {
|
||||||
|
sourceUri.lastPathSegment!!
|
||||||
|
}
|
||||||
|
if (pageId != null) {
|
||||||
|
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||||
|
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
|
||||||
|
}
|
||||||
|
val desiredFileName = desiredNameWithoutExtension + when (exportMimeType) {
|
||||||
|
MimeTypes.JPEG -> ".jpg"
|
||||||
|
MimeTypes.PNG -> ".png"
|
||||||
|
MimeTypes.WEBP -> ".webp"
|
||||||
|
else -> throw Exception("unsupported export MIME type=$exportMimeType")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File(destinationDir, desiredFileName).exists()) {
|
||||||
|
throw Exception("file with name=$desiredFileName already exists in destination directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, desiredNameWithoutExtension)
|
||||||
|
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||||
|
|
||||||
|
val model: Any = if (MimeTypes.isHeifLike(sourceMimeType) && pageId != null) {
|
||||||
|
MultiTrackImage(context, sourceUri, pageId)
|
||||||
|
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(context, sourceUri, pageId)
|
||||||
|
} else {
|
||||||
|
sourceUri
|
||||||
|
}
|
||||||
|
|
||||||
|
// request a fresh image with the highest quality format
|
||||||
|
val glideOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
|
val target = Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.apply(glideOptions)
|
||||||
|
.load(model)
|
||||||
|
.submit()
|
||||||
|
try {
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
var bitmap = target.get()
|
||||||
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
|
||||||
|
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
|
}
|
||||||
|
bitmap ?: throw Exception("failed to get image from uri=$sourceUri page=$pageId")
|
||||||
|
|
||||||
|
val quality = 100
|
||||||
|
val format = when (exportMimeType) {
|
||||||
|
MimeTypes.JPEG -> Bitmap.CompressFormat.JPEG
|
||||||
|
MimeTypes.PNG -> Bitmap.CompressFormat.PNG
|
||||||
|
MimeTypes.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
if (quality == 100) {
|
||||||
|
Bitmap.CompressFormat.WEBP_LOSSLESS
|
||||||
|
} else {
|
||||||
|
Bitmap.CompressFormat.WEBP_LOSSY
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
Bitmap.CompressFormat.WEBP
|
||||||
|
}
|
||||||
|
else -> throw Exception("unsupported export MIME type=$exportMimeType")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
destinationDocFile.openOutputStream().use {
|
||||||
|
bitmap.compress(format, quality, it)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Glide.with(context).clear(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = destinationDocFile.name
|
||||||
|
val destinationFullPath = destinationDir + fileName
|
||||||
|
|
||||||
|
return scanNewPath(context, destinationFullPath, exportMimeType)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||||
val oldFile = File(oldPath)
|
val oldFile = File(oldPath)
|
||||||
val newFile = File(oldFile.parent, newFilename)
|
val newFile = File(oldFile.parent, newFilename)
|
||||||
|
@ -147,9 +298,9 @@ abstract class ImageProvider {
|
||||||
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
|
// `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")
|
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
|
||||||
contentId = ContentUris.parseId(newUri)
|
contentId = ContentUris.parseId(newUri)
|
||||||
if (isImage(mimeType)) {
|
if (MimeTypes.isImage(mimeType)) {
|
||||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||||
} else if (isVideo(mimeType)) {
|
} else if (MimeTypes.isVideo(mimeType)) {
|
||||||
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -198,5 +349,3 @@ abstract class ImageProvider {
|
||||||
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
|
private val LOG_TAG = LogUtils.createTag(ImageProvider::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typealias FieldMap = MutableMap<String, Any?>
|
|
||||||
|
|
|
@ -8,8 +8,9 @@ import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.model.AvesImageEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.SourceImageEntry
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||||
|
@ -158,7 +159,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
// missing some attributes such as width, height, orientation.
|
// missing some attributes such as width, height, orientation.
|
||||||
// Also, the reported size of raw images is inconsistent across devices
|
// Also, the reported size of raw images is inconsistent across devices
|
||||||
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
// and Android versions (sometimes the raw size, sometimes the decoded size).
|
||||||
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
|
val entry = SourceEntry(entryMap).fillPreCatalogMetadata(context)
|
||||||
entryMap = entry.toMap()
|
entryMap = entry.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,7 +186,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
override suspend fun delete(context: Context, uri: Uri, path: String?) {
|
override suspend fun delete(context: Context, uri: Uri, path: String?) {
|
||||||
path ?: throw Exception("failed to delete file because path is null")
|
path ?: throw Exception("failed to delete file because path is null")
|
||||||
|
|
||||||
if (requireAccessPermission(context, path)) {
|
if (File(path).exists() && requireAccessPermission(context, path)) {
|
||||||
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
|
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
|
||||||
// but it doesn't delete the file, even if the app has the permission
|
// but it doesn't delete the file, even if the app has the permission
|
||||||
val df = getDocumentFile(context, path, uri)
|
val df = getDocumentFile(context, path, uri)
|
||||||
|
@ -203,7 +204,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
context: Context,
|
context: Context,
|
||||||
copy: Boolean,
|
copy: Boolean,
|
||||||
destinationDir: String,
|
destinationDir: String,
|
||||||
entries: List<AvesImageEntry>,
|
entries: List<AvesEntry>,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||||
|
|
|
@ -26,7 +26,7 @@ object BitmapUtils {
|
||||||
} catch (e: IllegalStateException) {
|
} catch (e: IllegalStateException) {
|
||||||
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
Log.e(LOG_TAG, "failed to get bytes from bitmap", e)
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||||
|
|
|
@ -9,10 +9,10 @@ object MimeTypes {
|
||||||
private const val BMP = "image/bmp"
|
private const val BMP = "image/bmp"
|
||||||
const val GIF = "image/gif"
|
const val GIF = "image/gif"
|
||||||
const val HEIC = "image/heic"
|
const val HEIC = "image/heic"
|
||||||
const val HEIF = "image/heif"
|
private const val HEIF = "image/heif"
|
||||||
private const val ICO = "image/x-icon"
|
private const val ICO = "image/x-icon"
|
||||||
private const val JPEG = "image/jpeg"
|
const val JPEG = "image/jpeg"
|
||||||
private const val PNG = "image/png"
|
const val PNG = "image/png"
|
||||||
const val TIFF = "image/tiff"
|
const val TIFF = "image/tiff"
|
||||||
private const val WBMP = "image/vnd.wap.wbmp"
|
private const val WBMP = "image/vnd.wap.wbmp"
|
||||||
const val WEBP = "image/webp"
|
const val WEBP = "image/webp"
|
||||||
|
@ -41,10 +41,9 @@ object MimeTypes {
|
||||||
|
|
||||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||||
|
|
||||||
fun isMultimedia(mimeType: String?) = when (mimeType) {
|
fun isHeifLike(mimeType: String?) = mimeType != null && (mimeType == HEIC || mimeType == HEIF)
|
||||||
HEIC, HEIF -> true
|
|
||||||
else -> isVideo(mimeType)
|
fun isMultimedia(mimeType: String?) = isVideo(mimeType) || isHeifLike(mimeType)
|
||||||
}
|
|
||||||
|
|
||||||
fun isRaw(mimeType: String): Boolean {
|
fun isRaw(mimeType: String): Boolean {
|
||||||
return when (mimeType) {
|
return when (mimeType) {
|
||||||
|
|
|
@ -9,7 +9,7 @@ buildscript {
|
||||||
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808
|
// TODO TLAD upgrade AGP to 4+ when this lands on stable: https://github.com/flutter/flutter/pull/70808
|
||||||
classpath 'com.android.tools.build:gradle:3.6.4'
|
classpath 'com.android.tools.build:gradle:3.6.4'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
classpath 'com.google.gms:google-services:4.3.4'
|
classpath 'com.google.gms:google-services:4.3.5'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import 'dart:ui' as ui show Codec;
|
||||||
|
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class AppIconImage extends ImageProvider<AppIconImageKey> {
|
class AppIconImage extends ImageProvider<AppIconImageKey> {
|
||||||
const AppIconImage({
|
const AppIconImage({
|
||||||
|
|
|
@ -2,10 +2,9 @@ import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui show Codec;
|
import 'dart:ui' as ui show Codec;
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class RegionProvider extends ImageProvider<RegionProviderKey> {
|
class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
final RegionProviderKey key;
|
final RegionProviderKey key;
|
||||||
|
@ -23,7 +22,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
codec: _loadAsync(key, decode),
|
codec: _loadAsync(key, decode),
|
||||||
scale: key.scale,
|
scale: key.scale,
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=${key.uri}, regionRect=${key.regionRect}');
|
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, region=${key.region}');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -31,6 +30,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
|
Future<ui.Codec> _loadAsync(RegionProviderKey key, DecoderCallback decode) async {
|
||||||
final uri = key.uri;
|
final uri = key.uri;
|
||||||
final mimeType = key.mimeType;
|
final mimeType = key.mimeType;
|
||||||
|
final pageId = key.pageId;
|
||||||
try {
|
try {
|
||||||
final bytes = await ImageFileService.getRegion(
|
final bytes = await ImageFileService.getRegion(
|
||||||
uri,
|
uri,
|
||||||
|
@ -38,9 +38,9 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
key.rotationDegrees,
|
key.rotationDegrees,
|
||||||
key.isFlipped,
|
key.isFlipped,
|
||||||
key.sampleSize,
|
key.sampleSize,
|
||||||
key.regionRect,
|
key.region,
|
||||||
key.imageSize,
|
key.imageSize,
|
||||||
page: key.page,
|
pageId: pageId,
|
||||||
taskKey: key,
|
taskKey: key,
|
||||||
);
|
);
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
|
@ -49,7 +49,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
return await decode(bytes);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||||
throw StateError('$mimeType region decoding failed');
|
throw StateError('$mimeType region decoding failed (page $pageId)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,21 +63,23 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class RegionProviderKey {
|
class RegionProviderKey {
|
||||||
|
// do not store the entry as it is, because the key should be constant
|
||||||
|
// but the entry attributes may change over time
|
||||||
final String uri, mimeType;
|
final String uri, mimeType;
|
||||||
final int rotationDegrees, sampleSize, page;
|
final int pageId, rotationDegrees, sampleSize;
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
final Rectangle<int> regionRect;
|
final Rectangle<int> region;
|
||||||
final Size imageSize;
|
final Size imageSize;
|
||||||
final double scale;
|
final double scale;
|
||||||
|
|
||||||
const RegionProviderKey({
|
const RegionProviderKey({
|
||||||
@required this.uri,
|
@required this.uri,
|
||||||
@required this.mimeType,
|
@required this.mimeType,
|
||||||
|
@required this.pageId,
|
||||||
@required this.rotationDegrees,
|
@required this.rotationDegrees,
|
||||||
@required this.isFlipped,
|
@required this.isFlipped,
|
||||||
this.page = 0,
|
|
||||||
@required this.sampleSize,
|
@required this.sampleSize,
|
||||||
@required this.regionRect,
|
@required this.region,
|
||||||
@required this.imageSize,
|
@required this.imageSize,
|
||||||
this.scale = 1.0,
|
this.scale = 1.0,
|
||||||
}) : assert(uri != null),
|
}) : assert(uri != null),
|
||||||
|
@ -85,49 +87,29 @@ class RegionProviderKey {
|
||||||
assert(rotationDegrees != null),
|
assert(rotationDegrees != null),
|
||||||
assert(isFlipped != null),
|
assert(isFlipped != null),
|
||||||
assert(sampleSize != null),
|
assert(sampleSize != null),
|
||||||
assert(regionRect != null),
|
assert(region != null),
|
||||||
assert(imageSize != null),
|
assert(imageSize != null),
|
||||||
assert(scale != null);
|
assert(scale != null);
|
||||||
|
|
||||||
// do not store the entry as it is, because the key should be constant
|
|
||||||
// but the entry attributes may change over time
|
|
||||||
factory RegionProviderKey.fromEntry(
|
|
||||||
ImageEntry entry, {
|
|
||||||
int page = 0,
|
|
||||||
@required int sampleSize,
|
|
||||||
@required Rectangle<int> rect,
|
|
||||||
}) {
|
|
||||||
return RegionProviderKey(
|
|
||||||
uri: entry.uri,
|
|
||||||
mimeType: entry.mimeType,
|
|
||||||
rotationDegrees: entry.rotationDegrees,
|
|
||||||
isFlipped: entry.isFlipped,
|
|
||||||
page: page,
|
|
||||||
sampleSize: sampleSize,
|
|
||||||
regionRect: rect,
|
|
||||||
imageSize: Size(entry.width.toDouble(), entry.height.toDouble()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.sampleSize == sampleSize && other.regionRect == regionRect && other.imageSize == imageSize && other.scale == scale;
|
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize && other.scale == scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => hashValues(
|
int get hashCode => hashValues(
|
||||||
uri,
|
uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
pageId,
|
||||||
rotationDegrees,
|
rotationDegrees,
|
||||||
isFlipped,
|
isFlipped,
|
||||||
page,
|
|
||||||
sampleSize,
|
sampleSize,
|
||||||
regionRect,
|
region,
|
||||||
imageSize,
|
imageSize,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize, scale=$scale}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import 'dart:ui' as ui show Codec;
|
import 'dart:ui' as ui show Codec;
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
final ThumbnailProviderKey key;
|
final ThumbnailProviderKey key;
|
||||||
|
@ -24,7 +23,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
codec: _loadAsync(key, decode),
|
codec: _loadAsync(key, decode),
|
||||||
scale: key.scale,
|
scale: key.scale,
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=${key.uri}, extent=${key.extent}');
|
yield ErrorDescription('uri=${key.uri}, pageId=${key.pageId}, mimeType=${key.mimeType}, extent=${key.extent}');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -32,16 +31,16 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
|
||||||
final uri = key.uri;
|
final uri = key.uri;
|
||||||
final mimeType = key.mimeType;
|
final mimeType = key.mimeType;
|
||||||
|
final pageId = key.pageId;
|
||||||
try {
|
try {
|
||||||
final bytes = await ImageFileService.getThumbnail(
|
final bytes = await ImageFileService.getThumbnail(
|
||||||
uri,
|
uri: uri,
|
||||||
mimeType,
|
mimeType: mimeType,
|
||||||
key.dateModifiedSecs,
|
pageId: pageId,
|
||||||
key.rotationDegrees,
|
rotationDegrees: key.rotationDegrees,
|
||||||
key.isFlipped,
|
isFlipped: key.isFlipped,
|
||||||
key.extent,
|
dateModifiedSecs: key.dateModifiedSecs,
|
||||||
key.extent,
|
extent: key.extent,
|
||||||
page: key.page,
|
|
||||||
taskKey: key,
|
taskKey: key,
|
||||||
);
|
);
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
|
@ -50,7 +49,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
return await decode(bytes);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
debugPrint('$runtimeType _loadAsync failed with uri=$uri, error=$error');
|
||||||
throw StateError('$mimeType decoding failed');
|
throw StateError('$mimeType decoding failed (page $pageId)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,61 +63,49 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailProviderKey {
|
class ThumbnailProviderKey {
|
||||||
|
// do not store the entry as it is, because the key should be constant
|
||||||
|
// but the entry attributes may change over time
|
||||||
final String uri, mimeType;
|
final String uri, mimeType;
|
||||||
final int dateModifiedSecs, rotationDegrees, page;
|
final int pageId, rotationDegrees;
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
|
final int dateModifiedSecs;
|
||||||
final double extent, scale;
|
final double extent, scale;
|
||||||
|
|
||||||
const ThumbnailProviderKey({
|
const ThumbnailProviderKey({
|
||||||
@required this.uri,
|
@required this.uri,
|
||||||
@required this.mimeType,
|
@required this.mimeType,
|
||||||
@required this.dateModifiedSecs,
|
@required this.pageId,
|
||||||
@required this.rotationDegrees,
|
@required this.rotationDegrees,
|
||||||
@required this.isFlipped,
|
@required this.isFlipped,
|
||||||
this.page = 0,
|
@required this.dateModifiedSecs,
|
||||||
this.extent = 0,
|
this.extent = 0,
|
||||||
this.scale = 1,
|
this.scale = 1,
|
||||||
}) : assert(uri != null),
|
}) : assert(uri != null),
|
||||||
assert(mimeType != null),
|
assert(mimeType != null),
|
||||||
assert(dateModifiedSecs != null),
|
|
||||||
assert(rotationDegrees != null),
|
assert(rotationDegrees != null),
|
||||||
assert(isFlipped != null),
|
assert(isFlipped != null),
|
||||||
|
assert(dateModifiedSecs != null),
|
||||||
assert(extent != null),
|
assert(extent != null),
|
||||||
assert(scale != null);
|
assert(scale != null);
|
||||||
|
|
||||||
// do not store the entry as it is, because the key should be constant
|
|
||||||
// but the entry attributes may change over time
|
|
||||||
factory ThumbnailProviderKey.fromEntry(ImageEntry entry, {int page = 0, double extent = 0}) {
|
|
||||||
return ThumbnailProviderKey(
|
|
||||||
uri: entry.uri,
|
|
||||||
mimeType: entry.mimeType,
|
|
||||||
// `dateModifiedSecs` can be missing in viewer mode
|
|
||||||
dateModifiedSecs: entry.dateModifiedSecs ?? -1,
|
|
||||||
rotationDegrees: entry.rotationDegrees,
|
|
||||||
isFlipped: entry.isFlipped,
|
|
||||||
page: page,
|
|
||||||
extent: extent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is ThumbnailProviderKey && other.uri == uri && other.extent == extent && other.mimeType == mimeType && other.dateModifiedSecs == dateModifiedSecs && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
return other is ThumbnailProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent && other.scale == scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => hashValues(
|
int get hashCode => hashValues(
|
||||||
uri,
|
uri,
|
||||||
mimeType,
|
mimeType,
|
||||||
dateModifiedSecs,
|
pageId,
|
||||||
rotationDegrees,
|
rotationDegrees,
|
||||||
isFlipped,
|
isFlipped,
|
||||||
page,
|
dateModifiedSecs,
|
||||||
extent,
|
extent,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, extent=$extent, scale=$scale}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent, scale=$scale}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,19 @@ import 'dart:ui' as ui show Codec;
|
||||||
|
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
class UriImage extends ImageProvider<UriImage> {
|
class UriImage extends ImageProvider<UriImage> {
|
||||||
final String uri, mimeType;
|
final String uri, mimeType;
|
||||||
final int page, rotationDegrees, expectedContentLength;
|
final int pageId, rotationDegrees, expectedContentLength;
|
||||||
final bool isFlipped;
|
final bool isFlipped;
|
||||||
final double scale;
|
final double scale;
|
||||||
|
|
||||||
const UriImage({
|
const UriImage({
|
||||||
@required this.uri,
|
@required this.uri,
|
||||||
@required this.mimeType,
|
@required this.mimeType,
|
||||||
this.page = 0,
|
@required this.pageId,
|
||||||
@required this.rotationDegrees,
|
@required this.rotationDegrees,
|
||||||
@required this.isFlipped,
|
@required this.isFlipped,
|
||||||
this.expectedContentLength,
|
this.expectedContentLength,
|
||||||
|
@ -37,7 +37,7 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
scale: key.scale,
|
scale: key.scale,
|
||||||
chunkEvents: chunkEvents.stream,
|
chunkEvents: chunkEvents.stream,
|
||||||
informationCollector: () sync* {
|
informationCollector: () sync* {
|
||||||
yield ErrorDescription('uri=$uri, mimeType=$mimeType');
|
yield ErrorDescription('uri=$uri, pageId=$pageId, mimeType=$mimeType');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -51,7 +51,7 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
mimeType,
|
mimeType,
|
||||||
rotationDegrees,
|
rotationDegrees,
|
||||||
isFlipped,
|
isFlipped,
|
||||||
page: page,
|
pageId: pageId,
|
||||||
expectedContentLength: expectedContentLength,
|
expectedContentLength: expectedContentLength,
|
||||||
onBytesReceived: (cumulative, total) {
|
onBytesReceived: (cumulative, total) {
|
||||||
chunkEvents.add(ImageChunkEvent(
|
chunkEvents.add(ImageChunkEvent(
|
||||||
|
@ -66,7 +66,7 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
return await decode(bytes);
|
return await decode(bytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
|
||||||
throw StateError('$mimeType decoding failed');
|
throw StateError('$mimeType decoding failed (page $pageId)');
|
||||||
} finally {
|
} finally {
|
||||||
unawaited(chunkEvents.close());
|
unawaited(chunkEvents.close());
|
||||||
}
|
}
|
||||||
|
@ -75,7 +75,7 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other.runtimeType != runtimeType) return false;
|
if (other.runtimeType != runtimeType) return false;
|
||||||
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.page == page && other.scale == scale;
|
return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.pageId == pageId && other.scale == scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -84,10 +84,10 @@ class UriImage extends ImageProvider<UriImage> {
|
||||||
mimeType,
|
mimeType,
|
||||||
rotationDegrees,
|
rotationDegrees,
|
||||||
isFlipped,
|
isFlipped,
|
||||||
page,
|
pageId,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, page=$page, scale=$scale}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ class UriPicture extends PictureProvider<UriPicture> {
|
||||||
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
|
||||||
assert(key == this);
|
assert(key == this);
|
||||||
|
|
||||||
final data = await ImageFileService.getImage(uri, mimeType, 0, false);
|
final data = await ImageFileService.getSvg(uri, mimeType);
|
||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
109
lib/main.dart
109
lib/main.dart
|
@ -2,10 +2,13 @@ import 'dart:isolate';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
import 'package:aves/model/source/media_store_source.dart';
|
||||||
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
|
import 'package:aves/utils/debouncer.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||||
import 'package:aves/widgets/common/providers/settings_provider.dart';
|
|
||||||
import 'package:aves/widgets/home_page.dart';
|
import 'package:aves/widgets/home_page.dart';
|
||||||
import 'package:aves/widgets/welcome_page.dart';
|
import 'package:aves/widgets/welcome_page.dart';
|
||||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
@ -16,6 +19,7 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:overlay_support/overlay_support.dart';
|
import 'package:overlay_support/overlay_support.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
|
||||||
|
@ -43,12 +47,16 @@ class AvesApp extends StatefulWidget {
|
||||||
|
|
||||||
class _AvesAppState extends State<AvesApp> {
|
class _AvesAppState extends State<AvesApp> {
|
||||||
Future<void> _appSetup;
|
Future<void> _appSetup;
|
||||||
|
final _mediaStoreSource = MediaStoreSource();
|
||||||
|
final Debouncer _contentChangeDebouncer = Debouncer(delay: Durations.contentChangeDebounceDelay);
|
||||||
|
final List<String> changedUris = [];
|
||||||
|
|
||||||
// observers are not registered when using the same list object with different items
|
// observers are not registered when using the same list object with different items
|
||||||
// the list itself needs to be reassigned
|
// the list itself needs to be reassigned
|
||||||
List<NavigatorObserver> _navigatorObservers = [];
|
List<NavigatorObserver> _navigatorObservers = [];
|
||||||
final _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
final EventChannel _contentChangeChannel = EventChannel('deckers.thibault/aves/contentchange');
|
||||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
final EventChannel _newIntentChannel = EventChannel('deckers.thibault/aves/intent');
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||||
|
|
||||||
static const accentColor = Colors.indigoAccent;
|
static const accentColor = Colors.indigoAccent;
|
||||||
|
|
||||||
|
@ -94,9 +102,57 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_appSetup = _setup();
|
_appSetup = _setup();
|
||||||
|
_contentChangeChannel.receiveBroadcastStream().listen((event) => _onContentChange(event as String));
|
||||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// place the settings provider above `MaterialApp`
|
||||||
|
// so it can be used during navigation transitions
|
||||||
|
return ChangeNotifierProvider<Settings>.value(
|
||||||
|
value: settings,
|
||||||
|
child: Provider<CollectionSource>.value(
|
||||||
|
value: _mediaStoreSource,
|
||||||
|
child: OverlaySupport(
|
||||||
|
child: FutureBuilder<void>(
|
||||||
|
future: _appSetup,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
||||||
|
? getFirstPage()
|
||||||
|
: Scaffold(
|
||||||
|
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
return MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
|
home: home,
|
||||||
|
navigatorObservers: _navigatorObservers,
|
||||||
|
title: 'Aves',
|
||||||
|
darkTheme: darkTheme,
|
||||||
|
themeMode: ThemeMode.dark,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildError(Object error) {
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(AIcons.error),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(error.toString()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _setup() async {
|
Future<void> _setup() async {
|
||||||
await Firebase.initializeApp().then((app) {
|
await Firebase.initializeApp().then((app) {
|
||||||
final crashlytics = FirebaseCrashlytics.instance;
|
final crashlytics = FirebaseCrashlytics.instance;
|
||||||
|
@ -133,46 +189,11 @@ class _AvesAppState extends State<AvesApp> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _onContentChange(String uri) {
|
||||||
Widget build(BuildContext context) {
|
changedUris.add(uri);
|
||||||
// place the settings provider above `MaterialApp`
|
_contentChangeDebouncer(() {
|
||||||
// so it can be used during navigation transitions
|
_mediaStoreSource.refreshUris(List.of(changedUris));
|
||||||
return SettingsProvider(
|
changedUris.clear();
|
||||||
child: OverlaySupport(
|
});
|
||||||
child: FutureBuilder<void>(
|
|
||||||
future: _appSetup,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
|
|
||||||
? getFirstPage()
|
|
||||||
: Scaffold(
|
|
||||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
return MaterialApp(
|
|
||||||
navigatorKey: _navigatorKey,
|
|
||||||
home: home,
|
|
||||||
navigatorObservers: _navigatorObservers,
|
|
||||||
title: 'Aves',
|
|
||||||
darkTheme: darkTheme,
|
|
||||||
themeMode: ThemeMode.dark,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildError(Object error) {
|
|
||||||
return Container(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(AIcons.error),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text(error.toString()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||||
enum EntryAction {
|
enum EntryAction {
|
||||||
delete,
|
delete,
|
||||||
edit,
|
edit,
|
||||||
|
export,
|
||||||
flip,
|
flip,
|
||||||
info,
|
info,
|
||||||
open,
|
open,
|
||||||
|
@ -31,6 +32,7 @@ class EntryActions {
|
||||||
EntryAction.share,
|
EntryAction.share,
|
||||||
EntryAction.delete,
|
EntryAction.delete,
|
||||||
EntryAction.rename,
|
EntryAction.rename,
|
||||||
|
EntryAction.export,
|
||||||
EntryAction.print,
|
EntryAction.print,
|
||||||
EntryAction.viewSource,
|
EntryAction.viewSource,
|
||||||
];
|
];
|
||||||
|
@ -52,6 +54,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return null;
|
return null;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
return 'Delete';
|
return 'Delete';
|
||||||
|
case EntryAction.export:
|
||||||
|
return 'Export';
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return 'Info';
|
return 'Info';
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
|
@ -91,6 +95,8 @@ extension ExtraEntryAction on EntryAction {
|
||||||
return null;
|
return null;
|
||||||
case EntryAction.delete:
|
case EntryAction.delete:
|
||||||
return AIcons.delete;
|
return AIcons.delete;
|
||||||
|
case EntryAction.export:
|
||||||
|
return AIcons.export;
|
||||||
case EntryAction.info:
|
case EntryAction.info:
|
||||||
return AIcons.info;
|
return AIcons.info;
|
||||||
case EntryAction.rename:
|
case EntryAction.rename:
|
||||||
|
|
1
lib/model/actions/move_type.dart
Normal file
1
lib/model/actions/move_type.dart
Normal file
|
@ -0,0 +1 @@
|
||||||
|
enum MoveType { copy, move, export }
|
28
lib/model/connectivity.dart
Normal file
28
lib/model/connectivity.dart
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:connectivity/connectivity.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
final AvesConnectivity connectivity = AvesConnectivity._private();
|
||||||
|
|
||||||
|
class AvesConnectivity {
|
||||||
|
bool _isConnected;
|
||||||
|
|
||||||
|
AvesConnectivity._private() {
|
||||||
|
Connectivity().onConnectivityChanged.listen(_updateFromResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onResume() => _isConnected = null;
|
||||||
|
|
||||||
|
Future<bool> get isConnected async {
|
||||||
|
if (_isConnected != null) return SynchronousFuture(_isConnected);
|
||||||
|
final result = await (Connectivity().checkConnectivity());
|
||||||
|
_updateFromResult(result);
|
||||||
|
return _isConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> get canGeolocate => isConnected;
|
||||||
|
|
||||||
|
void _updateFromResult(ConnectivityResult result) {
|
||||||
|
_isConnected = result != ConnectivityResult.none;
|
||||||
|
debugPrint('Device is connected=$_isConnected');
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry_cache.dart';
|
import 'package:aves/model/entry_cache.dart';
|
||||||
import 'package:aves/model/favourite_repo.dart';
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
|
@ -21,16 +21,18 @@ import 'package:path/path.dart' as ppath;
|
||||||
|
|
||||||
import '../ref/mime_types.dart';
|
import '../ref/mime_types.dart';
|
||||||
|
|
||||||
class ImageEntry {
|
class AvesEntry {
|
||||||
String uri;
|
String uri;
|
||||||
String _path, _directory, _filename, _extension;
|
String _path, _directory, _filename, _extension;
|
||||||
int contentId;
|
int pageId, contentId;
|
||||||
final String sourceMimeType;
|
final String sourceMimeType;
|
||||||
int width;
|
int width;
|
||||||
int height;
|
int height;
|
||||||
int sourceRotationDegrees;
|
int sourceRotationDegrees;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
String sourceTitle;
|
String sourceTitle;
|
||||||
|
|
||||||
|
// `dateModifiedSecs` can be missing in viewer mode
|
||||||
int _dateModifiedSecs;
|
int _dateModifiedSecs;
|
||||||
final int sourceDateTakenMillis;
|
final int sourceDateTakenMillis;
|
||||||
final int durationMillis;
|
final int durationMillis;
|
||||||
|
@ -43,10 +45,11 @@ class ImageEntry {
|
||||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||||
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
|
static const List<String> undecodable = [MimeTypes.crw, MimeTypes.psd];
|
||||||
|
|
||||||
ImageEntry({
|
AvesEntry({
|
||||||
this.uri,
|
this.uri,
|
||||||
String path,
|
String path,
|
||||||
this.contentId,
|
this.contentId,
|
||||||
|
this.pageId,
|
||||||
this.sourceMimeType,
|
this.sourceMimeType,
|
||||||
@required this.width,
|
@required this.width,
|
||||||
@required this.height,
|
@required this.height,
|
||||||
|
@ -66,14 +69,14 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||||
|
|
||||||
ImageEntry copyWith({
|
AvesEntry copyWith({
|
||||||
@required String uri,
|
@required String uri,
|
||||||
@required String path,
|
@required String path,
|
||||||
@required int contentId,
|
@required int contentId,
|
||||||
@required int dateModifiedSecs,
|
@required int dateModifiedSecs,
|
||||||
}) {
|
}) {
|
||||||
final copyContentId = contentId ?? this.contentId;
|
final copyContentId = contentId ?? this.contentId;
|
||||||
final copied = ImageEntry(
|
final copied = AvesEntry(
|
||||||
uri: uri ?? uri,
|
uri: uri ?? uri,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
contentId: copyContentId,
|
contentId: copyContentId,
|
||||||
|
@ -93,9 +96,39 @@ class ImageEntry {
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AvesEntry getPageEntry(SinglePageInfo pageInfo, {bool eraseDefaultPageId = true}) {
|
||||||
|
if (pageInfo == null) return this;
|
||||||
|
|
||||||
|
// do not provide the page ID for the default page,
|
||||||
|
// so that we can treat this page like the main entry
|
||||||
|
// and retrieve cached images for it
|
||||||
|
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
|
||||||
|
|
||||||
|
return AvesEntry(
|
||||||
|
uri: uri,
|
||||||
|
path: path,
|
||||||
|
contentId: contentId,
|
||||||
|
pageId: pageId,
|
||||||
|
sourceMimeType: pageInfo.mimeType ?? sourceMimeType,
|
||||||
|
width: pageInfo.width ?? width,
|
||||||
|
height: pageInfo.height ?? height,
|
||||||
|
sourceRotationDegrees: sourceRotationDegrees,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
sourceTitle: sourceTitle,
|
||||||
|
dateModifiedSecs: dateModifiedSecs,
|
||||||
|
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||||
|
durationMillis: pageInfo.durationMillis ?? durationMillis,
|
||||||
|
)
|
||||||
|
..catalogMetadata = _catalogMetadata?.copyWith(
|
||||||
|
mimeType: pageInfo.mimeType,
|
||||||
|
isMultipage: false,
|
||||||
|
)
|
||||||
|
..addressDetails = _addressDetails?.copyWith();
|
||||||
|
}
|
||||||
|
|
||||||
// from DB or platform source entry
|
// from DB or platform source entry
|
||||||
factory ImageEntry.fromMap(Map map) {
|
factory AvesEntry.fromMap(Map map) {
|
||||||
return ImageEntry(
|
return AvesEntry(
|
||||||
uri: map['uri'] as String,
|
uri: map['uri'] as String,
|
||||||
path: map['path'] as String,
|
path: map['path'] as String,
|
||||||
contentId: map['contentId'] as int,
|
contentId: map['contentId'] as int,
|
||||||
|
@ -136,7 +169,7 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}';
|
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
|
||||||
|
|
||||||
set path(String path) {
|
set path(String path) {
|
||||||
_path = path;
|
_path = path;
|
||||||
|
@ -196,7 +229,11 @@ class ImageEntry {
|
||||||
].contains(mimeType) &&
|
].contains(mimeType) &&
|
||||||
!isAnimated;
|
!isAnimated;
|
||||||
|
|
||||||
bool get canTile => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;
|
||||||
|
|
||||||
|
// as of panorama v0.3.1, the `Panorama` widget throws on initialization when the image is already resolved
|
||||||
|
// so we use tiles for panoramas as a workaround to not collide with the `panorama` package resolution
|
||||||
|
bool get useTiles => supportTiling && (width > 4096 || height > 4096 || is360);
|
||||||
|
|
||||||
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
|
||||||
|
|
||||||
|
@ -216,8 +253,6 @@ class ImageEntry {
|
||||||
|
|
||||||
bool get canEdit => path != null;
|
bool get canEdit => path != null;
|
||||||
|
|
||||||
bool get canPrint => !isVideo;
|
|
||||||
|
|
||||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||||
|
|
||||||
// support for writing EXIF
|
// support for writing EXIF
|
||||||
|
@ -233,29 +268,21 @@ class ImageEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The additional comparison of width to height is a workaround for badly registered entries.
|
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||||
// e.g. a portrait FHD video should be registered as width=1920, height=1080, orientation=90,
|
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||||
// but is incorrectly registered in the Media Store as width=1080, height=1920, orientation=0
|
// but is incorrectly registered as width=1080, height=1920, orientation=0.
|
||||||
// Double-checking the width/height during loading or cataloguing is the proper solution,
|
// Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
|
||||||
// but it would take space and time, so a basic workaround will do.
|
// Comparing width and height can help with the portrait FHD video example,
|
||||||
bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height);
|
// but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
|
||||||
|
bool get isRotated => rotationDegrees % 180 == 90;
|
||||||
|
|
||||||
static const ratioSeparator = '\u2236';
|
static const ratioSeparator = '\u2236';
|
||||||
static const resolutionSeparator = ' \u00D7 ';
|
static const resolutionSeparator = ' \u00D7 ';
|
||||||
|
|
||||||
String getResolutionText({MultiPageInfo multiPageInfo, int page}) {
|
String get resolutionText {
|
||||||
int w;
|
final ws = width ?? '?';
|
||||||
int h;
|
final hs = height ?? '?';
|
||||||
if (multiPageInfo != null && page != null) {
|
return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
||||||
final pageInfo = multiPageInfo.pages[page];
|
|
||||||
w = pageInfo?.width;
|
|
||||||
h = pageInfo?.height;
|
|
||||||
}
|
|
||||||
w ??= width;
|
|
||||||
h ??= height;
|
|
||||||
final ws = w ?? '?';
|
|
||||||
final hs = h ?? '?';
|
|
||||||
return isPortrait ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String get aspectRatioText {
|
String get aspectRatioText {
|
||||||
|
@ -263,7 +290,7 @@ class ImageEntry {
|
||||||
final gcd = width.gcd(height);
|
final gcd = width.gcd(height);
|
||||||
final w = width ~/ gcd;
|
final w = width ~/ gcd;
|
||||||
final h = height ~/ gcd;
|
final h = height ~/ gcd;
|
||||||
return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
|
||||||
} else {
|
} else {
|
||||||
return '?$ratioSeparator?';
|
return '?$ratioSeparator?';
|
||||||
}
|
}
|
||||||
|
@ -271,20 +298,13 @@ class ImageEntry {
|
||||||
|
|
||||||
double get displayAspectRatio {
|
double get displayAspectRatio {
|
||||||
if (width == 0 || height == 0) return 1;
|
if (width == 0 || height == 0) return 1;
|
||||||
return isPortrait ? height / width : width / height;
|
return isRotated ? height / width : width / height;
|
||||||
}
|
}
|
||||||
|
|
||||||
Size getDisplaySize({MultiPageInfo multiPageInfo, int page}) {
|
Size get displaySize {
|
||||||
int w;
|
final w = width.toDouble();
|
||||||
int h;
|
final h = height.toDouble();
|
||||||
if (multiPageInfo != null && page != null) {
|
return isRotated ? Size(h, w) : Size(w, h);
|
||||||
final pageInfo = multiPageInfo.pages[page];
|
|
||||||
w = pageInfo?.width;
|
|
||||||
h = pageInfo?.height;
|
|
||||||
}
|
|
||||||
w ??= width;
|
|
||||||
h ??= height;
|
|
||||||
return isPortrait ? Size(h.toDouble(), w.toDouble()) : Size(w.toDouble(), h.toDouble());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
|
||||||
|
@ -598,7 +618,7 @@ class ImageEntry {
|
||||||
// compare by:
|
// compare by:
|
||||||
// 1) title ascending
|
// 1) title ascending
|
||||||
// 2) extension ascending
|
// 2) extension ascending
|
||||||
static int compareByName(ImageEntry a, ImageEntry b) {
|
static int compareByName(AvesEntry a, AvesEntry b) {
|
||||||
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
final c = compareAsciiUpperCase(a.bestTitle, b.bestTitle);
|
||||||
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
return c != 0 ? c : compareAsciiUpperCase(a.extension, b.extension);
|
||||||
}
|
}
|
||||||
|
@ -606,7 +626,7 @@ class ImageEntry {
|
||||||
// compare by:
|
// compare by:
|
||||||
// 1) size descending
|
// 1) size descending
|
||||||
// 2) name ascending
|
// 2) name ascending
|
||||||
static int compareBySize(ImageEntry a, ImageEntry b) {
|
static int compareBySize(AvesEntry a, AvesEntry b) {
|
||||||
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
final c = b.sizeBytes.compareTo(a.sizeBytes);
|
||||||
return c != 0 ? c : compareByName(a, b);
|
return c != 0 ? c : compareByName(a, b);
|
||||||
}
|
}
|
||||||
|
@ -615,9 +635,12 @@ class ImageEntry {
|
||||||
|
|
||||||
// compare by:
|
// compare by:
|
||||||
// 1) date descending
|
// 1) date descending
|
||||||
// 2) name ascending
|
// 2) name descending
|
||||||
static int compareByDate(ImageEntry a, ImageEntry b) {
|
static int compareByDate(AvesEntry a, AvesEntry b) {
|
||||||
final c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
|
||||||
return c != 0 ? c : compareByName(a, b);
|
if (c != 0) return c;
|
||||||
|
c = (b.dateModifiedSecs ?? 0).compareTo(a.dateModifiedSecs ?? 0);
|
||||||
|
if (c != 0) return c;
|
||||||
|
return -compareByName(a, b);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,14 +12,14 @@ class EntryCache {
|
||||||
int oldRotationDegrees,
|
int oldRotationDegrees,
|
||||||
bool oldIsFlipped,
|
bool oldIsFlipped,
|
||||||
) async {
|
) async {
|
||||||
// TODO TLAD revisit this for multipage items, if someday image editing features are added for them
|
// TODO TLAD provide pageId parameter for multipage items, if someday image editing features are added for them
|
||||||
const page = 0;
|
int pageId;
|
||||||
|
|
||||||
// evict fullscreen image
|
// evict fullscreen image
|
||||||
await UriImage(
|
await UriImage(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
page: page,
|
pageId: pageId,
|
||||||
rotationDegrees: oldRotationDegrees,
|
rotationDegrees: oldRotationDegrees,
|
||||||
isFlipped: oldIsFlipped,
|
isFlipped: oldIsFlipped,
|
||||||
).evict();
|
).evict();
|
||||||
|
@ -28,10 +28,10 @@ class EntryCache {
|
||||||
await ThumbnailProvider(ThumbnailProviderKey(
|
await ThumbnailProvider(ThumbnailProviderKey(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
|
pageId: pageId,
|
||||||
dateModifiedSecs: dateModifiedSecs,
|
dateModifiedSecs: dateModifiedSecs,
|
||||||
rotationDegrees: oldRotationDegrees,
|
rotationDegrees: oldRotationDegrees,
|
||||||
isFlipped: oldIsFlipped,
|
isFlipped: oldIsFlipped,
|
||||||
page: page,
|
|
||||||
)).evict();
|
)).evict();
|
||||||
|
|
||||||
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
// evict higher quality thumbnails (with powers of 2 from 32 to 1024 as specified extents)
|
||||||
|
@ -41,10 +41,10 @@ class EntryCache {
|
||||||
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
(extent) => ThumbnailProvider(ThumbnailProviderKey(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType,
|
||||||
|
pageId: pageId,
|
||||||
dateModifiedSecs: dateModifiedSecs,
|
dateModifiedSecs: dateModifiedSecs,
|
||||||
rotationDegrees: oldRotationDegrees,
|
rotationDegrees: oldRotationDegrees,
|
||||||
isFlipped: oldIsFlipped,
|
isFlipped: oldIsFlipped,
|
||||||
page: page,
|
|
||||||
extent: extent,
|
extent: extent,
|
||||||
)).evict());
|
)).evict());
|
||||||
}
|
}
|
||||||
|
|
67
lib/model/entry_images.dart
Normal file
67
lib/model/entry_images.dart
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:aves/image_providers/region_provider.dart';
|
||||||
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
|
import 'package:aves/image_providers/uri_image_provider.dart';
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
extension ExtraAvesEntry on AvesEntry {
|
||||||
|
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||||
|
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
||||||
|
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
||||||
|
// so that there are less variants of the thumbnails to load and cache
|
||||||
|
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
||||||
|
final requestExtent = extent == 0 ? .0 : pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
||||||
|
|
||||||
|
return ThumbnailProviderKey(
|
||||||
|
uri: uri,
|
||||||
|
mimeType: mimeType,
|
||||||
|
pageId: pageId,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
isFlipped: isFlipped,
|
||||||
|
dateModifiedSecs: dateModifiedSecs ?? -1,
|
||||||
|
extent: requestExtent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RegionProvider getRegion({@required int sampleSize, Rectangle<int> region}) {
|
||||||
|
return RegionProvider(_getRegionProviderKey(sampleSize, region));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegionProviderKey _getRegionProviderKey(int sampleSize, Rectangle<int> region) {
|
||||||
|
return RegionProviderKey(
|
||||||
|
uri: uri,
|
||||||
|
mimeType: mimeType,
|
||||||
|
pageId: pageId,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
isFlipped: isFlipped,
|
||||||
|
sampleSize: sampleSize,
|
||||||
|
region: region ?? Rectangle<int>(0, 0, width, height),
|
||||||
|
imageSize: Size(width.toDouble(), height.toDouble()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UriImage get uriImage => UriImage(
|
||||||
|
uri: uri,
|
||||||
|
mimeType: mimeType,
|
||||||
|
pageId: pageId,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
isFlipped: isFlipped,
|
||||||
|
expectedContentLength: sizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive;
|
||||||
|
|
||||||
|
ImageProvider getBestThumbnail(double extent) {
|
||||||
|
final sizedThumbnailKey = _getThumbnailProviderKey(extent);
|
||||||
|
if (_isReady(sizedThumbnailKey)) return ThumbnailProvider(sizedThumbnailKey);
|
||||||
|
|
||||||
|
return getThumbnail();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
|
|
||||||
|
@ -18,25 +18,25 @@ class FavouriteRepo {
|
||||||
|
|
||||||
int get count => _rows.length;
|
int get count => _rows.length;
|
||||||
|
|
||||||
bool isFavourite(ImageEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
|
||||||
|
|
||||||
FavouriteRow _entryToRow(ImageEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
|
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId, path: entry.path);
|
||||||
|
|
||||||
Future<void> add(Iterable<ImageEntry> entries) async {
|
Future<void> add(Iterable<AvesEntry> entries) async {
|
||||||
final newRows = entries.map(_entryToRow);
|
final newRows = entries.map(_entryToRow);
|
||||||
await metadataDb.addFavourites(newRows);
|
await metadataDb.addFavourites(newRows);
|
||||||
_rows.addAll(newRows);
|
_rows.addAll(newRows);
|
||||||
changeNotifier.notifyListeners();
|
changeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> remove(Iterable<ImageEntry> entries) async {
|
Future<void> remove(Iterable<AvesEntry> entries) async {
|
||||||
final removedRows = entries.map(_entryToRow);
|
final removedRows = entries.map(_entryToRow);
|
||||||
await metadataDb.removeFavourites(removedRows);
|
await metadataDb.removeFavourites(removedRows);
|
||||||
removedRows.forEach(_rows.remove);
|
removedRows.forEach(_rows.remove);
|
||||||
changeNotifier.notifyListeners();
|
changeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> move(int oldContentId, ImageEntry entry) async {
|
Future<void> move(int oldContentId, AvesEntry entry) async {
|
||||||
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
|
final oldRow = _rows.firstWhere((row) => row.contentId == oldContentId, orElse: () => null);
|
||||||
if (oldRow != null) {
|
if (oldRow != null) {
|
||||||
_rows.remove(oldRow);
|
_rows.remove(oldRow);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||||
|
@ -33,7 +33,7 @@ class AlbumFilter extends CollectionFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => entry.directory == album;
|
bool filter(AvesEntry entry) => entry.directory == album;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get label => uniqueName ?? album.split(separator).last;
|
String get label => uniqueName ?? album.split(separator).last;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
@ -13,7 +13,7 @@ class FavouriteFilter extends CollectionFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => entry.isFavourite;
|
bool filter(AvesEntry entry) => entry.isFavourite;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get label => 'Favourite';
|
String get label => 'Favourite';
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
import 'package:aves/model/filters/mime.dart';
|
||||||
import 'package:aves/model/filters/query.dart';
|
import 'package:aves/model/filters/query.dart';
|
||||||
import 'package:aves/model/filters/tag.dart';
|
import 'package:aves/model/filters/tag.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/utils/color_utils.dart';
|
import 'package:aves/utils/color_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -49,7 +49,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
||||||
|
|
||||||
String toJson() => jsonEncode(toMap());
|
String toJson() => jsonEncode(toMap());
|
||||||
|
|
||||||
bool filter(ImageEntry entry);
|
bool filter(AvesEntry entry);
|
||||||
|
|
||||||
bool get isUnique => true;
|
bool get isUnique => true;
|
||||||
|
|
||||||
|
@ -78,7 +78,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
||||||
// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
|
// TODO TLAD replace this by adding getters to CollectionFilter, with cached entry/count coming from Source
|
||||||
class FilterGridItem<T extends CollectionFilter> {
|
class FilterGridItem<T extends CollectionFilter> {
|
||||||
final T filter;
|
final T filter;
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const FilterGridItem(this.filter, this.entry);
|
const FilterGridItem(this.filter, this.entry);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
@ -35,7 +35,7 @@ class LocationFilter extends CollectionFilter {
|
||||||
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
bool filter(AvesEntry entry) => _location.isEmpty ? !entry.isLocated : entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryCode == _countryCode) || (level == LocationLevel.place && entry.addressDetails.place == _location));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get label => _location.isEmpty ? emptyLabel : _location;
|
String get label => _location.isEmpty ? emptyLabel : _location;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/mime_utils.dart';
|
import 'package:aves/utils/mime_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -15,7 +15,7 @@ class MimeFilter extends CollectionFilter {
|
||||||
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
|
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
|
||||||
|
|
||||||
final String mime;
|
final String mime;
|
||||||
bool Function(ImageEntry) _filter;
|
bool Function(AvesEntry) _filter;
|
||||||
String _label;
|
String _label;
|
||||||
IconData _icon;
|
IconData _icon;
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ class MimeFilter extends CollectionFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => _filter(entry);
|
bool filter(AvesEntry entry) => _filter(entry);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get label => _label;
|
String get label => _label;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -12,7 +12,7 @@ class QueryFilter extends CollectionFilter {
|
||||||
|
|
||||||
final String query;
|
final String query;
|
||||||
final bool colorful;
|
final bool colorful;
|
||||||
bool Function(ImageEntry) _filter;
|
bool Function(AvesEntry) _filter;
|
||||||
|
|
||||||
QueryFilter(this.query, {this.colorful = true}) {
|
QueryFilter(this.query, {this.colorful = true}) {
|
||||||
var upQuery = query.toUpperCase();
|
var upQuery = query.toUpperCase();
|
||||||
|
@ -44,7 +44,7 @@ class QueryFilter extends CollectionFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => _filter(entry);
|
bool filter(AvesEntry entry) => _filter(entry);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isUnique => false;
|
bool get isUnique => false;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
@ -24,7 +24,7 @@ class TagFilter extends CollectionFilter {
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool filter(ImageEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
|
bool filter(AvesEntry entry) => tag.isEmpty ? entry.xmpSubjects.isEmpty : entry.xmpSubjects.contains(tag);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isUnique => false;
|
bool get isUnique => false;
|
||||||
|
|
|
@ -68,17 +68,19 @@ class CatalogMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
CatalogMetadata copyWith({
|
CatalogMetadata copyWith({
|
||||||
@required int contentId,
|
int contentId,
|
||||||
|
String mimeType,
|
||||||
|
bool isMultipage,
|
||||||
}) {
|
}) {
|
||||||
return CatalogMetadata(
|
return CatalogMetadata(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
||||||
mimeType: mimeType,
|
mimeType: mimeType ?? this.mimeType,
|
||||||
dateMillis: dateMillis,
|
dateMillis: dateMillis,
|
||||||
isAnimated: isAnimated,
|
isAnimated: isAnimated,
|
||||||
isFlipped: isFlipped,
|
isFlipped: isFlipped,
|
||||||
isGeotiff: isGeotiff,
|
isGeotiff: isGeotiff,
|
||||||
is360: is360,
|
is360: is360,
|
||||||
isMultipage: isMultipage,
|
isMultipage: isMultipage ?? this.isMultipage,
|
||||||
rotationDegrees: rotationDegrees,
|
rotationDegrees: rotationDegrees,
|
||||||
xmpSubjects: xmpSubjects,
|
xmpSubjects: xmpSubjects,
|
||||||
xmpTitleDescription: xmpTitleDescription,
|
xmpTitleDescription: xmpTitleDescription,
|
||||||
|
@ -169,7 +171,7 @@ class AddressDetails {
|
||||||
});
|
});
|
||||||
|
|
||||||
AddressDetails copyWith({
|
AddressDetails copyWith({
|
||||||
@required int contentId,
|
int contentId,
|
||||||
}) {
|
}) {
|
||||||
return AddressDetails(
|
return AddressDetails(
|
||||||
contentId: contentId ?? this.contentId,
|
contentId: contentId ?? this.contentId,
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
@ -116,16 +116,16 @@ class MetadataDb {
|
||||||
debugPrint('$runtimeType clearEntries deleted $count entries');
|
debugPrint('$runtimeType clearEntries deleted $count entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ImageEntry>> loadEntries() async {
|
Future<List<AvesEntry>> loadEntries() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final maps = await db.query(entryTable);
|
final maps = await db.query(entryTable);
|
||||||
final entries = maps.map((map) => ImageEntry.fromMap(map)).toList();
|
final entries = maps.map((map) => AvesEntry.fromMap(map)).toList();
|
||||||
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;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveEntries(Iterable<ImageEntry> entries) async {
|
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||||
if (entries == null || entries.isEmpty) return;
|
if (entries == null || entries.isEmpty) return;
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
|
@ -135,7 +135,7 @@ class MetadataDb {
|
||||||
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateEntryId(int oldId, ImageEntry entry) async {
|
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
|
||||||
final db = await _database;
|
final db = await _database;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
|
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
|
||||||
|
@ -143,7 +143,7 @@ class MetadataDb {
|
||||||
await batch.commit(noResult: true);
|
await batch.commit(noResult: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _batchInsertEntry(Batch batch, ImageEntry entry) {
|
void _batchInsertEntry(Batch batch, AvesEntry entry) {
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
batch.insert(
|
batch.insert(
|
||||||
entryTable,
|
entryTable,
|
||||||
|
|
|
@ -1,42 +1,83 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class SinglePageInfo {
|
|
||||||
final int width, height;
|
|
||||||
|
|
||||||
SinglePageInfo({
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory SinglePageInfo.fromMap(Map map) {
|
|
||||||
return SinglePageInfo(
|
|
||||||
width: map['width'] as int,
|
|
||||||
height: map['height'] as int,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{width=$width, height=$height}';
|
|
||||||
}
|
|
||||||
|
|
||||||
class MultiPageInfo {
|
class MultiPageInfo {
|
||||||
final Map<int, SinglePageInfo> pages;
|
final List<SinglePageInfo> pages;
|
||||||
|
|
||||||
int get pageCount => pages.length;
|
int get pageCount => pages.length;
|
||||||
|
|
||||||
MultiPageInfo({
|
MultiPageInfo({
|
||||||
this.pages,
|
this.pages,
|
||||||
});
|
}) {
|
||||||
|
if (pages.isNotEmpty) {
|
||||||
factory MultiPageInfo.fromMap(Map map) {
|
pages.sort();
|
||||||
final pages = <int, SinglePageInfo>{};
|
// make sure there is a page marked as default
|
||||||
map.keys.forEach((key) {
|
if (defaultPage == null) {
|
||||||
final index = key as int;
|
final firstPage = pages.removeAt(0);
|
||||||
pages.putIfAbsent(index, () => SinglePageInfo.fromMap(map[key]));
|
pages.insert(0, firstPage.copyWith(isDefault: true));
|
||||||
});
|
|
||||||
return MultiPageInfo(pages: pages);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MultiPageInfo.fromPageMaps(List<Map> pageMaps) {
|
||||||
|
return MultiPageInfo(pages: pageMaps.map((page) => SinglePageInfo.fromMap(page)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
SinglePageInfo get defaultPage => pages.firstWhere((page) => page.isDefault, orElse: () => null);
|
||||||
|
|
||||||
|
SinglePageInfo getByIndex(int index) => pages.firstWhere((page) => page.index == index, orElse: () => null);
|
||||||
|
|
||||||
|
SinglePageInfo getById(int pageId) => pages.firstWhere((page) => page.pageId == pageId, orElse: () => null);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
|
String toString() => '$runtimeType#${shortHash(this)}{pages=$pages}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SinglePageInfo implements Comparable<SinglePageInfo> {
|
||||||
|
final int index, pageId;
|
||||||
|
final String mimeType;
|
||||||
|
final bool isDefault;
|
||||||
|
final int width, height, durationMillis;
|
||||||
|
|
||||||
|
const SinglePageInfo({
|
||||||
|
this.index,
|
||||||
|
this.pageId,
|
||||||
|
this.mimeType,
|
||||||
|
this.isDefault,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.durationMillis,
|
||||||
|
});
|
||||||
|
|
||||||
|
SinglePageInfo copyWith({
|
||||||
|
bool isDefault,
|
||||||
|
}) {
|
||||||
|
return SinglePageInfo(
|
||||||
|
index: index,
|
||||||
|
pageId: pageId,
|
||||||
|
mimeType: mimeType,
|
||||||
|
isDefault: isDefault ?? this.isDefault,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
durationMillis: durationMillis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SinglePageInfo.fromMap(Map map) {
|
||||||
|
final index = map['page'] as int;
|
||||||
|
return SinglePageInfo(
|
||||||
|
index: index,
|
||||||
|
pageId: map['trackId'] as int ?? index,
|
||||||
|
mimeType: map['mimeType'] as String,
|
||||||
|
isDefault: map['isDefault'] as bool ?? false,
|
||||||
|
width: map['width'] as int ?? 0,
|
||||||
|
height: map['height'] as int ?? 0,
|
||||||
|
durationMillis: map['durationMillis'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{index=$index, pageId=$pageId, mimeType=$mimeType, isDefault=$isDefault, width=$width, height=$height, durationMillis=$durationMillis}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
int compareTo(SinglePageInfo other) => index.compareTo(other.index);
|
||||||
|
}
|
||||||
|
|
|
@ -4,24 +4,38 @@ import 'package:flutter/widgets.dart';
|
||||||
class PanoramaInfo {
|
class PanoramaInfo {
|
||||||
final Rect croppedAreaRect;
|
final Rect croppedAreaRect;
|
||||||
final Size fullPanoSize;
|
final Size fullPanoSize;
|
||||||
|
final String projectionType;
|
||||||
|
|
||||||
PanoramaInfo({
|
PanoramaInfo({
|
||||||
this.croppedAreaRect,
|
this.croppedAreaRect,
|
||||||
this.fullPanoSize,
|
this.fullPanoSize,
|
||||||
|
this.projectionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PanoramaInfo.fromMap(Map map) {
|
factory PanoramaInfo.fromMap(Map map) {
|
||||||
final cLeft = map['croppedAreaLeft'] as int;
|
var cLeft = map['croppedAreaLeft'] as int;
|
||||||
final cTop = map['croppedAreaTop'] as int;
|
var cTop = map['croppedAreaTop'] as int;
|
||||||
final cWidth = map['croppedAreaWidth'] as int;
|
final cWidth = map['croppedAreaWidth'] as int;
|
||||||
final cHeight = map['croppedAreaHeight'] as int;
|
final cHeight = map['croppedAreaHeight'] as int;
|
||||||
|
var fWidth = map['fullPanoWidth'] as int;
|
||||||
|
var fHeight = map['fullPanoHeight'] as int;
|
||||||
|
final projectionType = map['projectionType'] as String;
|
||||||
|
|
||||||
|
// handle missing `fullPanoHeight` (e.g. Samsung camera app panorama mode)
|
||||||
|
if (fHeight == null && cWidth != null && cHeight != null) {
|
||||||
|
// assume the cropped area is actually covering 360 degrees horizontally
|
||||||
|
// even when `croppedAreaLeft` is non zero
|
||||||
|
fWidth = cWidth;
|
||||||
|
fHeight = (fWidth / 2).round();
|
||||||
|
cTop = ((fHeight - cHeight) / 2).round();
|
||||||
|
cLeft = 0;
|
||||||
|
}
|
||||||
|
|
||||||
Rect croppedAreaRect;
|
Rect croppedAreaRect;
|
||||||
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
|
if (cLeft != null && cTop != null && cWidth != null && cHeight != null) {
|
||||||
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
|
croppedAreaRect = Rect.fromLTWH(cLeft.toDouble(), cTop.toDouble(), cWidth.toDouble(), cHeight.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
final fWidth = map['fullPanoWidth'] as int;
|
|
||||||
final fHeight = map['fullPanoHeight'] as int;
|
|
||||||
Size fullPanoSize;
|
Size fullPanoSize;
|
||||||
if (fWidth != null && fHeight != null) {
|
if (fWidth != null && fHeight != null) {
|
||||||
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
|
fullPanoSize = Size(fWidth.toDouble(), fHeight.toDouble());
|
||||||
|
@ -30,11 +44,12 @@ class PanoramaInfo {
|
||||||
return PanoramaInfo(
|
return PanoramaInfo(
|
||||||
croppedAreaRect: croppedAreaRect,
|
croppedAreaRect: croppedAreaRect,
|
||||||
fullPanoSize: fullPanoSize,
|
fullPanoSize: fullPanoSize,
|
||||||
|
projectionType: projectionType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
|
bool get hasCroppedArea => croppedAreaRect != null && fullPanoSize != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize}';
|
String toString() => '$runtimeType#${shortHash(this)}{croppedAreaRect=$croppedAreaRect, fullPanoSize=$fullPanoSize, projectionType=$projectionType}';
|
||||||
}
|
}
|
||||||
|
|
34
lib/model/settings/map_style.dart
Normal file
34
lib/model/settings/map_style.dart
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||||
|
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
|
||||||
|
|
||||||
|
extension ExtraEntryMapStyle on EntryMapStyle {
|
||||||
|
String get name {
|
||||||
|
switch (this) {
|
||||||
|
case EntryMapStyle.googleNormal:
|
||||||
|
return 'Google Maps';
|
||||||
|
case EntryMapStyle.googleHybrid:
|
||||||
|
return 'Google Maps (Hybrid)';
|
||||||
|
case EntryMapStyle.googleTerrain:
|
||||||
|
return 'Google Maps (Terrain)';
|
||||||
|
case EntryMapStyle.osmHot:
|
||||||
|
return 'Humanitarian OSM';
|
||||||
|
case EntryMapStyle.stamenToner:
|
||||||
|
return 'Stamen Toner';
|
||||||
|
case EntryMapStyle.stamenWatercolor:
|
||||||
|
return 'Stamen Watercolor';
|
||||||
|
default:
|
||||||
|
return toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isGoogleMaps {
|
||||||
|
switch (this) {
|
||||||
|
case EntryMapStyle.googleNormal:
|
||||||
|
case EntryMapStyle.googleHybrid:
|
||||||
|
case EntryMapStyle.googleTerrain:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,8 +2,8 @@ import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/settings/coordinate_format.dart';
|
import 'package:aves/model/settings/coordinate_format.dart';
|
||||||
import 'package:aves/model/settings/entry_background.dart';
|
import 'package:aves/model/settings/entry_background.dart';
|
||||||
import 'package:aves/model/settings/home_page.dart';
|
import 'package:aves/model/settings/home_page.dart';
|
||||||
|
import 'package:aves/model/settings/map_style.dart';
|
||||||
import 'package:aves/model/settings/screen_on.dart';
|
import 'package:aves/model/settings/screen_on.dart';
|
||||||
import 'package:aves/widgets/viewer/info/location_section.dart';
|
|
||||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
@ -50,10 +50,10 @@ mixin AlbumMixin on SourceBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, ImageEntry> getAlbumEntries() {
|
Map<String, AvesEntry> getAlbumEntries() {
|
||||||
final entries = sortedEntriesForFilterList;
|
final entries = sortedEntriesForFilterList;
|
||||||
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
|
||||||
for (var album in sortedAlbums) {
|
for (final album in sortedAlbums) {
|
||||||
switch (androidFileUtils.getAlbumType(album)) {
|
switch (androidFileUtils.getAlbumType(album)) {
|
||||||
case AlbumType.regular:
|
case AlbumType.regular:
|
||||||
regularAlbums.add(album);
|
regularAlbums.add(album);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/model/source/section_keys.dart';
|
import 'package:aves/model/source/section_keys.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
|
@ -19,24 +19,28 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
EntryGroupFactor groupFactor;
|
EntryGroupFactor groupFactor;
|
||||||
EntrySortFactor sortFactor;
|
EntrySortFactor sortFactor;
|
||||||
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
final AChangeNotifier filterChangeNotifier = AChangeNotifier();
|
||||||
|
bool listenToSource;
|
||||||
|
|
||||||
List<ImageEntry> _filteredEntries;
|
List<AvesEntry> _filteredEntries;
|
||||||
List<StreamSubscription> _subscriptions = [];
|
List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
Map<SectionKey, List<ImageEntry>> sections = Map.unmodifiable({});
|
Map<SectionKey, List<AvesEntry>> sections = Map.unmodifiable({});
|
||||||
|
|
||||||
CollectionLens({
|
CollectionLens({
|
||||||
@required this.source,
|
@required this.source,
|
||||||
Iterable<CollectionFilter> filters,
|
Iterable<CollectionFilter> filters,
|
||||||
@required EntryGroupFactor groupFactor,
|
@required EntryGroupFactor groupFactor,
|
||||||
@required EntrySortFactor sortFactor,
|
@required EntrySortFactor sortFactor,
|
||||||
|
this.listenToSource = true,
|
||||||
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
}) : filters = {if (filters != null) ...filters.where((f) => f != null)},
|
||||||
groupFactor = groupFactor ?? EntryGroupFactor.month,
|
groupFactor = groupFactor ?? EntryGroupFactor.month,
|
||||||
sortFactor = sortFactor ?? EntrySortFactor.date {
|
sortFactor = sortFactor ?? EntrySortFactor.date {
|
||||||
|
if (listenToSource) {
|
||||||
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(source.eventBus.on<EntryAddedEvent>().listen((e) => _refresh()));
|
||||||
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
_subscriptions.add(source.eventBus.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries)));
|
||||||
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(source.eventBus.on<EntryMovedEvent>().listen((e) => _refresh()));
|
||||||
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
_subscriptions.add(source.eventBus.on<CatalogMetadataChangedEvent>().listen((e) => _refresh()));
|
||||||
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,23 +53,14 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionLens derive(CollectionFilter filter) {
|
|
||||||
return CollectionLens(
|
|
||||||
source: source,
|
|
||||||
filters: filters,
|
|
||||||
groupFactor: groupFactor,
|
|
||||||
sortFactor: sortFactor,
|
|
||||||
)..addFilter(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isEmpty => _filteredEntries.isEmpty;
|
bool get isEmpty => _filteredEntries.isEmpty;
|
||||||
|
|
||||||
int get entryCount => _filteredEntries.length;
|
int get entryCount => _filteredEntries.length;
|
||||||
|
|
||||||
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
// sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries
|
||||||
List<ImageEntry> _sortedEntries;
|
List<AvesEntry> _sortedEntries;
|
||||||
|
|
||||||
List<ImageEntry> get sortedEntries {
|
List<AvesEntry> get sortedEntries {
|
||||||
_sortedEntries ??= List.of(sections.entries.expand((e) => e.value));
|
_sortedEntries ??= List.of(sections.entries.expand((e) => e.value));
|
||||||
return _sortedEntries;
|
return _sortedEntries;
|
||||||
}
|
}
|
||||||
|
@ -82,7 +77,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object heroTag(ImageEntry entry) => '$hashCode${entry.uri}';
|
Object heroTag(AvesEntry entry) => entry.uri;
|
||||||
|
|
||||||
void addFilter(CollectionFilter filter) {
|
void addFilter(CollectionFilter filter) {
|
||||||
if (filter == null || filters.contains(filter)) return;
|
if (filter == null || filters.contains(filter)) return;
|
||||||
|
@ -123,13 +118,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
void _applySort() {
|
void _applySort() {
|
||||||
switch (sortFactor) {
|
switch (sortFactor) {
|
||||||
case EntrySortFactor.date:
|
case EntrySortFactor.date:
|
||||||
_filteredEntries.sort(ImageEntry.compareByDate);
|
_filteredEntries.sort(AvesEntry.compareByDate);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.size:
|
case EntrySortFactor.size:
|
||||||
_filteredEntries.sort(ImageEntry.compareBySize);
|
_filteredEntries.sort(AvesEntry.compareBySize);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
case EntrySortFactor.name:
|
||||||
_filteredEntries.sort(ImageEntry.compareByName);
|
_filteredEntries.sort(AvesEntry.compareByName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,13 +134,13 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
case EntrySortFactor.date:
|
case EntrySortFactor.date:
|
||||||
switch (groupFactor) {
|
switch (groupFactor) {
|
||||||
case EntryGroupFactor.album:
|
case EntryGroupFactor.album:
|
||||||
sections = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.month:
|
case EntryGroupFactor.month:
|
||||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.monthTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.day:
|
case EntryGroupFactor.day:
|
||||||
sections = groupBy<ImageEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
sections = groupBy<AvesEntry, EntryDateSectionKey>(_filteredEntries, (entry) => EntryDateSectionKey(entry.dayTaken));
|
||||||
break;
|
break;
|
||||||
case EntryGroupFactor.none:
|
case EntryGroupFactor.none:
|
||||||
sections = Map.fromEntries([
|
sections = Map.fromEntries([
|
||||||
|
@ -160,8 +155,8 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
case EntrySortFactor.name:
|
||||||
final byAlbum = groupBy<ImageEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
final byAlbum = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredEntries, (entry) => EntryAlbumSectionKey(entry.directory));
|
||||||
sections = SplayTreeMap<EntryAlbumSectionKey, List<ImageEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
sections = SplayTreeMap<EntryAlbumSectionKey, List<AvesEntry>>.of(byAlbum, (a, b) => source.compareAlbumsByName(a.folderPath, b.folderPath));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sections = Map.unmodifiable(sections);
|
sections = Map.unmodifiable(sections);
|
||||||
|
@ -177,7 +172,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
|
||||||
_applyGroup();
|
_applyGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onEntryRemoved(Iterable<ImageEntry> entries) {
|
void onEntryRemoved(Iterable<AvesEntry> entries) {
|
||||||
// we should remove obsolete entries and sections
|
// we should remove obsolete entries and sections
|
||||||
// but do not apply sort/group
|
// but do not apply sort/group
|
||||||
// as section order change would surprise the user while browsing
|
// as section order change would surprise the user while browsing
|
||||||
|
@ -207,18 +202,18 @@ mixin CollectionActivityMixin {
|
||||||
mixin CollectionSelectionMixin on CollectionActivityMixin {
|
mixin CollectionSelectionMixin on CollectionActivityMixin {
|
||||||
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
|
final AChangeNotifier selectionChangeNotifier = AChangeNotifier();
|
||||||
|
|
||||||
final Set<ImageEntry> _selection = {};
|
final Set<AvesEntry> _selection = {};
|
||||||
|
|
||||||
Set<ImageEntry> get selection => _selection;
|
Set<AvesEntry> get selection => _selection;
|
||||||
|
|
||||||
bool isSelected(Iterable<ImageEntry> entries) => entries.every(selection.contains);
|
bool isSelected(Iterable<AvesEntry> entries) => entries.every(selection.contains);
|
||||||
|
|
||||||
void addToSelection(Iterable<ImageEntry> entries) {
|
void addToSelection(Iterable<AvesEntry> entries) {
|
||||||
_selection.addAll(entries);
|
_selection.addAll(entries);
|
||||||
selectionChangeNotifier.notifyListeners();
|
selectionChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeFromSelection(Iterable<ImageEntry> entries) {
|
void removeFromSelection(Iterable<AvesEntry> entries) {
|
||||||
_selection.removeAll(entries);
|
_selection.removeAll(entries);
|
||||||
selectionChangeNotifier.notifyListeners();
|
selectionChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
@ -228,7 +223,7 @@ mixin CollectionSelectionMixin on CollectionActivityMixin {
|
||||||
selectionChangeNotifier.notifyListeners();
|
selectionChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleSelection(ImageEntry entry) {
|
void toggleSelection(AvesEntry entry) {
|
||||||
if (_selection.isEmpty) select();
|
if (_selection.isEmpty) select();
|
||||||
if (!_selection.remove(entry)) _selection.add(entry);
|
if (!_selection.remove(entry)) _selection.add(entry);
|
||||||
selectionChangeNotifier.notifyListeners();
|
selectionChangeNotifier.notifyListeners();
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourite_repo.dart';
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/source/album.dart';
|
import 'package:aves/model/source/album.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/location.dart';
|
import 'package:aves/model/source/location.dart';
|
||||||
import 'package:aves/model/source/tag.dart';
|
import 'package:aves/model/source/tag.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:event_bus/event_bus.dart';
|
import 'package:event_bus/event_bus.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'enums.dart';
|
import 'enums.dart';
|
||||||
|
|
||||||
mixin SourceBase {
|
mixin SourceBase {
|
||||||
final List<ImageEntry> _rawEntries = [];
|
final List<AvesEntry> _rawEntries = [];
|
||||||
|
|
||||||
List<ImageEntry> get rawEntries => List.unmodifiable(_rawEntries);
|
List<AvesEntry> get rawEntries => List.unmodifiable(_rawEntries);
|
||||||
|
|
||||||
final EventBus _eventBus = EventBus();
|
final EventBus _eventBus = EventBus();
|
||||||
|
|
||||||
EventBus get eventBus => _eventBus;
|
EventBus get eventBus => _eventBus;
|
||||||
|
|
||||||
List<ImageEntry> get sortedEntriesForFilterList;
|
List<AvesEntry> get sortedEntriesForFilterList;
|
||||||
|
|
||||||
final Map<CollectionFilter, int> _filterEntryCountMap = {};
|
final Map<CollectionFilter, int> _filterEntryCountMap = {};
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ mixin SourceBase {
|
||||||
|
|
||||||
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
||||||
@override
|
@override
|
||||||
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens(
|
List<AvesEntry> get sortedEntriesForFilterList => CollectionLens(
|
||||||
source: this,
|
source: this,
|
||||||
groupFactor: EntryGroupFactor.none,
|
groupFactor: EntryGroupFactor.none,
|
||||||
sortFactor: EntrySortFactor.date,
|
sortFactor: EntrySortFactor.date,
|
||||||
|
@ -55,7 +55,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
|
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
void addAll(Iterable<ImageEntry> entries) {
|
void addAll(Iterable<AvesEntry> entries) {
|
||||||
|
if (entries.isEmpty) return;
|
||||||
if (_rawEntries.isNotEmpty) {
|
if (_rawEntries.isNotEmpty) {
|
||||||
final newContentIds = entries.map((entry) => entry.contentId).toList();
|
final newContentIds = entries.map((entry) => entry.contentId).toList();
|
||||||
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
|
||||||
|
@ -70,7 +71,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
eventBus.fire(EntryAddedEvent());
|
eventBus.fire(EntryAddedEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeEntries(List<ImageEntry> entries) {
|
void removeEntries(List<AvesEntry> entries) {
|
||||||
entries.forEach((entry) => entry.removeFromFavourites());
|
entries.forEach((entry) => entry.removeFromFavourites());
|
||||||
_rawEntries.removeWhere(entries.contains);
|
_rawEntries.removeWhere(entries.contains);
|
||||||
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
||||||
|
@ -91,7 +92,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
|
|
||||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||||
// but it does not change when renaming the containing directory
|
// but it does not change when renaming the containing directory
|
||||||
Future<void> moveEntry(ImageEntry entry, Map newFields) async {
|
Future<void> moveEntry(AvesEntry entry, Map newFields) async {
|
||||||
final oldContentId = entry.contentId;
|
final oldContentId = entry.contentId;
|
||||||
final newContentId = newFields['contentId'] as int;
|
final newContentId = newFields['contentId'] as int;
|
||||||
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
final newDateModifiedSecs = newFields['dateModifiedSecs'] as int;
|
||||||
|
@ -109,7 +110,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateAfterMove({
|
void updateAfterMove({
|
||||||
@required Set<ImageEntry> selection,
|
@required Set<AvesEntry> selection,
|
||||||
@required bool copy,
|
@required bool copy,
|
||||||
@required String destinationAlbum,
|
@required String destinationAlbum,
|
||||||
@required Iterable<MoveOpEvent> movedOps,
|
@required Iterable<MoveOpEvent> movedOps,
|
||||||
|
@ -117,7 +118,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
if (movedOps.isEmpty) return;
|
if (movedOps.isEmpty) return;
|
||||||
|
|
||||||
final fromAlbums = <String>{};
|
final fromAlbums = <String>{};
|
||||||
final movedEntries = <ImageEntry>[];
|
final movedEntries = <AvesEntry>[];
|
||||||
if (copy) {
|
if (copy) {
|
||||||
movedOps.forEach((movedOp) {
|
movedOps.forEach((movedOp) {
|
||||||
final sourceUri = movedOp.uri;
|
final sourceUri = movedOp.uri;
|
||||||
|
@ -164,27 +165,31 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
||||||
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get initialized => false;
|
||||||
|
|
||||||
|
Future<void> init();
|
||||||
|
|
||||||
Future<void> refresh();
|
Future<void> refresh();
|
||||||
|
|
||||||
Future<void> refreshMetadata(Set<ImageEntry> entries);
|
Future<void> refreshMetadata(Set<AvesEntry> entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SourceState { loading, cataloguing, locating, ready }
|
enum SourceState { loading, cataloguing, locating, ready }
|
||||||
|
|
||||||
class EntryAddedEvent {
|
class EntryAddedEvent {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
|
|
||||||
const EntryAddedEvent([this.entry]);
|
const EntryAddedEvent([this.entry]);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EntryRemovedEvent {
|
class EntryRemovedEvent {
|
||||||
final Iterable<ImageEntry> entries;
|
final Iterable<AvesEntry> entries;
|
||||||
|
|
||||||
const EntryRemovedEvent(this.entries);
|
const EntryRemovedEvent(this.entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EntryMovedEvent {
|
class EntryMovedEvent {
|
||||||
final Iterable<ImageEntry> entries;
|
final Iterable<AvesEntry> entries;
|
||||||
|
|
||||||
const EntryMovedEvent(this.entries);
|
const EntryMovedEvent(this.entries);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/connectivity.dart';
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/location.dart';
|
import 'package:aves/model/filters/location.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -27,8 +28,10 @@ mixin LocationMixin on SourceBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> locateEntries() async {
|
Future<void> locateEntries() async {
|
||||||
|
if (!(await connectivity.canGeolocate)) return;
|
||||||
|
|
||||||
// final stopwatch = Stopwatch()..start();
|
// final stopwatch = Stopwatch()..start();
|
||||||
final byLocated = groupBy<ImageEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
|
final byLocated = groupBy<AvesEntry, bool>(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated);
|
||||||
final todo = byLocated[false] ?? [];
|
final todo = byLocated[false] ?? [];
|
||||||
if (todo.isEmpty) return;
|
if (todo.isEmpty) return;
|
||||||
|
|
||||||
|
@ -42,7 +45,7 @@ mixin LocationMixin on SourceBase {
|
||||||
// - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
|
// - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
|
||||||
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
|
// cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
|
||||||
final latLngFactor = pow(10, 2);
|
final latLngFactor = pow(10, 2);
|
||||||
Tuple2 approximateLatLng(ImageEntry entry) {
|
Tuple2 approximateLatLng(AvesEntry entry) {
|
||||||
final lat = entry.catalogMetadata?.latitude;
|
final lat = entry.catalogMetadata?.latitude;
|
||||||
final lng = entry.catalogMetadata?.longitude;
|
final lng = entry.catalogMetadata?.longitude;
|
||||||
if (lat == null || lng == null) return null;
|
if (lat == null || lng == null) return null;
|
||||||
|
@ -57,7 +60,7 @@ mixin LocationMixin on SourceBase {
|
||||||
setProgress(done: progressDone, total: progressTotal);
|
setProgress(done: progressDone, total: progressTotal);
|
||||||
|
|
||||||
final newAddresses = <AddressDetails>[];
|
final newAddresses = <AddressDetails>[];
|
||||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||||
final latLng = approximateLatLng(entry);
|
final latLng = approximateLatLng(entry);
|
||||||
if (knownLocations.containsKey(latLng)) {
|
if (knownLocations.containsKey(latLng)) {
|
||||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourite_repo.dart';
|
import 'package:aves/model/favourite_repo.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
@ -13,6 +14,12 @@ import 'package:flutter_native_timezone/flutter_native_timezone.dart';
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
|
||||||
class MediaStoreSource extends CollectionSource {
|
class MediaStoreSource extends CollectionSource {
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get initialized => _initialized;
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
|
@ -28,11 +35,13 @@ class MediaStoreSource extends CollectionSource {
|
||||||
settings.catalogTimeZone = currentTimeZone;
|
settings.catalogTimeZone = currentTimeZone;
|
||||||
}
|
}
|
||||||
await loadDates(); // 100ms for 5400 entries
|
await loadDates(); // 100ms for 5400 entries
|
||||||
|
_initialized = true;
|
||||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
|
assert(_initialized);
|
||||||
debugPrint('$runtimeType refresh start');
|
debugPrint('$runtimeType refresh start');
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
stateNotifier.value = SourceState.loading;
|
stateNotifier.value = SourceState.loading;
|
||||||
|
@ -40,8 +49,8 @@ class MediaStoreSource extends CollectionSource {
|
||||||
|
|
||||||
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
final oldEntries = await metadataDb.loadEntries(); // 400ms for 5500 entries
|
||||||
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
final knownEntryMap = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
|
||||||
final obsoleteEntries = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(knownEntryMap.keys.toList())).toSet();
|
||||||
oldEntries.removeWhere((entry) => obsoleteEntries.contains(entry.contentId));
|
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
|
||||||
|
|
||||||
// show known entries
|
// show known entries
|
||||||
addAll(oldEntries);
|
addAll(oldEntries);
|
||||||
|
@ -50,19 +59,20 @@ class MediaStoreSource extends CollectionSource {
|
||||||
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
debugPrint('$runtimeType refresh loaded ${oldEntries.length} known entries, elapsed=${stopwatch.elapsed}');
|
||||||
|
|
||||||
// clean up obsolete entries
|
// clean up obsolete entries
|
||||||
metadataDb.removeIds(obsoleteEntries, updateFavourites: true);
|
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||||
|
|
||||||
// fetch new entries
|
// fetch new entries
|
||||||
|
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
|
||||||
var refreshCount = 10;
|
var refreshCount = 10;
|
||||||
const refreshCountMax = 1000;
|
const refreshCountMax = 1000;
|
||||||
final allNewEntries = <ImageEntry>[], pendingNewEntries = <ImageEntry>[];
|
final allNewEntries = <AvesEntry>[], pendingNewEntries = <AvesEntry>[];
|
||||||
void addPendingEntries() {
|
void addPendingEntries() {
|
||||||
allNewEntries.addAll(pendingNewEntries);
|
allNewEntries.addAll(pendingNewEntries);
|
||||||
addAll(pendingNewEntries);
|
addAll(pendingNewEntries);
|
||||||
pendingNewEntries.clear();
|
pendingNewEntries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageFileService.getImageEntries(knownEntryMap).listen(
|
ImageFileService.getEntries(knownEntryMap).listen(
|
||||||
(entry) {
|
(entry) {
|
||||||
pendingNewEntries.add(entry);
|
pendingNewEntries.add(entry);
|
||||||
if (pendingNewEntries.length >= refreshCount) {
|
if (pendingNewEntries.length >= refreshCount) {
|
||||||
|
@ -95,8 +105,48 @@ class MediaStoreSource extends CollectionSource {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshUris(List<String> changedUris) async {
|
||||||
|
if (!_initialized) return;
|
||||||
|
|
||||||
|
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
|
||||||
|
if (uri == null) return null;
|
||||||
|
final idString = Uri.parse(uri).pathSegments.last;
|
||||||
|
final contentId = int.tryParse(idString);
|
||||||
|
if (contentId == null) return null;
|
||||||
|
return MapEntry(contentId, uri);
|
||||||
|
}).where((kv) => kv != null));
|
||||||
|
|
||||||
|
// clean up obsolete entries
|
||||||
|
final obsoleteContentIds = (await ImageFileService.getObsoleteEntries(uriByContentId.keys.toList())).toSet();
|
||||||
|
uriByContentId.removeWhere((contentId, _) => obsoleteContentIds.contains(contentId));
|
||||||
|
metadataDb.removeIds(obsoleteContentIds, updateFavourites: true);
|
||||||
|
|
||||||
|
// add new entries
|
||||||
|
final newEntries = <AvesEntry>[];
|
||||||
|
for (final kv in uriByContentId.entries) {
|
||||||
|
final contentId = kv.key;
|
||||||
|
final uri = kv.value;
|
||||||
|
final sourceEntry = await ImageFileService.getEntry(uri, null);
|
||||||
|
final existingEntry = rawEntries.firstWhere((entry) => entry.contentId == contentId, orElse: () => null);
|
||||||
|
if (existingEntry == null || sourceEntry.dateModifiedSecs > existingEntry.dateModifiedSecs) {
|
||||||
|
newEntries.add(sourceEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addAll(newEntries);
|
||||||
|
await metadataDb.saveEntries(newEntries);
|
||||||
|
updateAlbums();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.cataloguing;
|
||||||
|
await catalogEntries();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.locating;
|
||||||
|
await locateEntries();
|
||||||
|
|
||||||
|
stateNotifier.value = SourceState.ready;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> refreshMetadata(Set<ImageEntry> entries) {
|
Future<void> refreshMetadata(Set<AvesEntry> entries) {
|
||||||
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
final contentIds = entries.map((entry) => entry.contentId).toSet();
|
||||||
metadataDb.removeIds(contentIds, updateFavourites: false);
|
metadataDb.removeIds(contentIds, updateFavourites: false);
|
||||||
return refresh();
|
return refresh();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db.dart';
|
import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
@ -31,7 +31,7 @@ mixin TagMixin on SourceBase {
|
||||||
setProgress(done: progressDone, total: progressTotal);
|
setProgress(done: progressDone, total: progressTotal);
|
||||||
|
|
||||||
final newMetadata = <CatalogMetadata>[];
|
final newMetadata = <CatalogMetadata>[];
|
||||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||||
await entry.catalog(background: true);
|
await entry.catalog(background: true);
|
||||||
if (entry.isCatalogued) {
|
if (entry.isCatalogued) {
|
||||||
newMetadata.add(entry.catalogMetadata);
|
newMetadata.add(entry.catalogMetadata);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -8,12 +9,18 @@ import 'package:flutter/services.dart';
|
||||||
class AndroidAppService {
|
class AndroidAppService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/app');
|
static const platform = MethodChannel('deckers.thibault/aves/app');
|
||||||
|
|
||||||
static Future<Map> getAppNames() async {
|
static Future<Set<Package>> getPackages() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getAppNames');
|
final result = await platform.invokeMethod('getPackages');
|
||||||
return result as Map;
|
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
|
||||||
|
// additional info for known directories
|
||||||
|
final kakaoTalk = packages.firstWhere((package) => package.packageName == 'com.kakao.talk', orElse: () => null);
|
||||||
|
if (kakaoTalk != null) {
|
||||||
|
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('getAppNames failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -81,10 +88,10 @@ class AndroidAppService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> shareEntries(Iterable<ImageEntry> entries) async {
|
static Future<bool> shareEntries(Iterable<AvesEntry> entries) async {
|
||||||
// loosen mime type to a generic one, so we can share with badly defined apps
|
// loosen mime type to a generic one, so we can share with badly defined apps
|
||||||
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
|
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
|
||||||
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
|
||||||
try {
|
try {
|
||||||
return await platform.invokeMethod('share', <String, dynamic>{
|
return await platform.invokeMethod('share', <String, dynamic>{
|
||||||
'title': 'Share via:',
|
'title': 'Share via:',
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -26,7 +26,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getBitmapFactoryInfo(ImageEntry entry) async {
|
static Future<Map> getBitmapFactoryInfo(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with all data available when decoding image bounds with `BitmapFactory`
|
// return map with all data available when decoding image bounds with `BitmapFactory`
|
||||||
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
final result = await platform.invokeMethod('getBitmapFactoryInfo', <String, dynamic>{
|
||||||
|
@ -39,7 +39,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getContentResolverMetadata(ImageEntry entry) async {
|
static Future<Map> getContentResolverMetadata(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with all data available from the content resolver
|
// return map with all data available from the content resolver
|
||||||
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getContentResolverMetadata', <String, dynamic>{
|
||||||
|
@ -53,7 +53,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
|
static Future<Map> getExifInterfaceMetadata(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with all data available from the `ExifInterface` library
|
// return map with all data available from the `ExifInterface` library
|
||||||
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
|
||||||
|
@ -68,7 +68,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
|
static Future<Map> getMediaMetadataRetrieverMetadata(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with all data available from `MediaMetadataRetriever`
|
// return map with all data available from `MediaMetadataRetriever`
|
||||||
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
|
||||||
|
@ -81,7 +81,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getMetadataExtractorSummary(ImageEntry entry) async {
|
static Future<Map> getMetadataExtractorSummary(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with the mime type and tag count for each directory found by `metadata-extractor`
|
// return map with the mime type and tag count for each directory found by `metadata-extractor`
|
||||||
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
|
final result = await platform.invokeMethod('getMetadataExtractorSummary', <String, dynamic>{
|
||||||
|
@ -96,7 +96,7 @@ class AndroidDebugService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> getTiffStructure(ImageEntry entry) async {
|
static Future<Map> getTiffStructure(AvesEntry entry) async {
|
||||||
if (entry.mimeType != MimeTypes.tiff) return {};
|
if (entry.mimeType != MimeTypes.tiff) return {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -9,14 +9,14 @@ class AndroidFileService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
||||||
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
||||||
|
|
||||||
static Future<List<Map>> getStorageVolumes() async {
|
static Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getStorageVolumes');
|
final result = await platform.invokeMethod('getStorageVolumes');
|
||||||
return (result as List).cast<Map>();
|
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||||
}
|
}
|
||||||
return [];
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<int> getFreeSpace(StorageVolume volume) async {
|
static Future<int> getFreeSpace(StorageVolume volume) async {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
@ -26,18 +26,18 @@ class AppShortcutService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> pin(String label, ImageEntry iconEntry, Set<CollectionFilter> filters) async {
|
static Future<void> pin(String label, AvesEntry entry, Set<CollectionFilter> filters) async {
|
||||||
Uint8List iconBytes;
|
Uint8List iconBytes;
|
||||||
if (iconEntry != null) {
|
if (entry != null) {
|
||||||
final size = iconEntry.isVideo ? 0.0 : 256.0;
|
final size = entry.isVideo ? 0.0 : 256.0;
|
||||||
iconBytes = await ImageFileService.getThumbnail(
|
iconBytes = await ImageFileService.getThumbnail(
|
||||||
iconEntry.uri,
|
uri: entry.uri,
|
||||||
iconEntry.mimeType,
|
mimeType: entry.mimeType,
|
||||||
iconEntry.dateModifiedSecs,
|
pageId: entry.pageId,
|
||||||
iconEntry.rotationDegrees,
|
rotationDegrees: entry.rotationDegrees,
|
||||||
iconEntry.isFlipped,
|
isFlipped: entry.isFlipped,
|
||||||
size,
|
dateModifiedSecs: entry.dateModifiedSecs,
|
||||||
size,
|
extent: size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -3,12 +3,12 @@ import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:streams_channel/streams_channel.dart';
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
|
||||||
class ImageFileService {
|
class ImageFileService {
|
||||||
|
@ -18,10 +18,11 @@ class ImageFileService {
|
||||||
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
|
||||||
static const double thumbnailDefaultSize = 64.0;
|
static const double thumbnailDefaultSize = 64.0;
|
||||||
|
|
||||||
static Map<String, dynamic> _toPlatformEntryMap(ImageEntry entry) {
|
static Map<String, dynamic> _toPlatformEntryMap(AvesEntry entry) {
|
||||||
return {
|
return {
|
||||||
'uri': entry.uri,
|
'uri': entry.uri,
|
||||||
'path': entry.path,
|
'path': entry.path,
|
||||||
|
'pageId': entry.pageId,
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'width': entry.width,
|
'width': entry.width,
|
||||||
'height': entry.height,
|
'height': entry.height,
|
||||||
|
@ -32,13 +33,13 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// knownEntries: map of contentId -> dateModifiedSecs
|
// knownEntries: map of contentId -> dateModifiedSecs
|
||||||
static Stream<ImageEntry> getImageEntries(Map<int, int> knownEntries) {
|
static Stream<AvesEntry> getEntries(Map<int, int> knownEntries) {
|
||||||
try {
|
try {
|
||||||
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
|
return mediaStoreChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'knownEntries': knownEntries,
|
'knownEntries': knownEntries,
|
||||||
}).map((event) => ImageEntry.fromMap(event));
|
}).map((event) => AvesEntry.fromMap(event));
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
return Stream.error(e);
|
return Stream.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,26 +56,40 @@ class ImageFileService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<ImageEntry> getImageEntry(String uri, String mimeType) async {
|
static Future<AvesEntry> getEntry(String uri, String mimeType) async {
|
||||||
debugPrint('getImageEntry for uri=$uri, mimeType=$mimeType');
|
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getImageEntry', <String, dynamic>{
|
final result = await platform.invokeMethod('getEntry', <String, dynamic>{
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
}) as Map;
|
}) as Map;
|
||||||
return ImageEntry.fromMap(result);
|
return AvesEntry.fromMap(result);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('getImageEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Uint8List> getSvg(
|
||||||
|
String uri,
|
||||||
|
String mimeType, {
|
||||||
|
int expectedContentLength,
|
||||||
|
BytesReceivedCallback onBytesReceived,
|
||||||
|
}) =>
|
||||||
|
getImage(
|
||||||
|
uri,
|
||||||
|
mimeType,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
expectedContentLength: expectedContentLength,
|
||||||
|
onBytesReceived: onBytesReceived,
|
||||||
|
);
|
||||||
|
|
||||||
static Future<Uint8List> getImage(
|
static Future<Uint8List> getImage(
|
||||||
String uri,
|
String uri,
|
||||||
String mimeType,
|
String mimeType,
|
||||||
int rotationDegrees,
|
int rotationDegrees,
|
||||||
bool isFlipped, {
|
bool isFlipped, {
|
||||||
int page = 0,
|
int pageId,
|
||||||
int expectedContentLength,
|
int expectedContentLength,
|
||||||
BytesReceivedCallback onBytesReceived,
|
BytesReceivedCallback onBytesReceived,
|
||||||
}) {
|
}) {
|
||||||
|
@ -87,7 +102,7 @@ class ImageFileService {
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
'rotationDegrees': rotationDegrees ?? 0,
|
'rotationDegrees': rotationDegrees ?? 0,
|
||||||
'isFlipped': isFlipped ?? false,
|
'isFlipped': isFlipped ?? false,
|
||||||
'page': page ?? 0,
|
'pageId': pageId,
|
||||||
}).listen(
|
}).listen(
|
||||||
(data) {
|
(data) {
|
||||||
final chunk = data as Uint8List;
|
final chunk = data as Uint8List;
|
||||||
|
@ -125,7 +140,7 @@ class ImageFileService {
|
||||||
int sampleSize,
|
int sampleSize,
|
||||||
Rectangle<int> regionRect,
|
Rectangle<int> regionRect,
|
||||||
Size imageSize, {
|
Size imageSize, {
|
||||||
int page = 0,
|
int pageId,
|
||||||
Object taskKey,
|
Object taskKey,
|
||||||
int priority,
|
int priority,
|
||||||
}) {
|
}) {
|
||||||
|
@ -135,7 +150,7 @@ class ImageFileService {
|
||||||
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
|
final result = await platform.invokeMethod('getRegion', <String, dynamic>{
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
'mimeType': mimeType,
|
'mimeType': mimeType,
|
||||||
'page': page,
|
'pageId': pageId,
|
||||||
'sampleSize': sampleSize,
|
'sampleSize': sampleSize,
|
||||||
'regionX': regionRect.left,
|
'regionX': regionRect.left,
|
||||||
'regionY': regionRect.top,
|
'regionY': regionRect.top,
|
||||||
|
@ -155,15 +170,14 @@ class ImageFileService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Uint8List> getThumbnail(
|
static Future<Uint8List> getThumbnail({
|
||||||
String uri,
|
@required String uri,
|
||||||
String mimeType,
|
@required String mimeType,
|
||||||
int dateModifiedSecs,
|
@required int rotationDegrees,
|
||||||
int rotationDegrees,
|
@required int pageId,
|
||||||
bool isFlipped,
|
@required bool isFlipped,
|
||||||
double width,
|
@required int dateModifiedSecs,
|
||||||
double height, {
|
@required double extent,
|
||||||
int page,
|
|
||||||
Object taskKey,
|
Object taskKey,
|
||||||
int priority,
|
int priority,
|
||||||
}) {
|
}) {
|
||||||
|
@ -179,9 +193,9 @@ class ImageFileService {
|
||||||
'dateModifiedSecs': dateModifiedSecs,
|
'dateModifiedSecs': dateModifiedSecs,
|
||||||
'rotationDegrees': rotationDegrees,
|
'rotationDegrees': rotationDegrees,
|
||||||
'isFlipped': isFlipped,
|
'isFlipped': isFlipped,
|
||||||
'widthDip': width,
|
'widthDip': extent,
|
||||||
'heightDip': height,
|
'heightDip': extent,
|
||||||
'page': page,
|
'pageId': pageId,
|
||||||
'defaultSizeDip': thumbnailDefaultSize,
|
'defaultSizeDip': thumbnailDefaultSize,
|
||||||
});
|
});
|
||||||
return result as Uint8List;
|
return result as Uint8List;
|
||||||
|
@ -191,7 +205,7 @@ class ImageFileService {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
|
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
|
||||||
priority: priority ?? (width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
|
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
|
||||||
key: taskKey,
|
key: taskKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -210,7 +224,7 @@ class ImageFileService {
|
||||||
|
|
||||||
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
static Future<T> resumeLoading<T>(Object taskKey) => servicePolicy.resume<T>(taskKey);
|
||||||
|
|
||||||
static Stream<ImageOpEvent> delete(Iterable<ImageEntry> entries) {
|
static Stream<ImageOpEvent> delete(Iterable<AvesEntry> entries) {
|
||||||
try {
|
try {
|
||||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'op': 'delete',
|
'op': 'delete',
|
||||||
|
@ -222,7 +236,11 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Stream<MoveOpEvent> move(Iterable<ImageEntry> entries, {@required bool copy, @required String destinationAlbum}) {
|
static Stream<MoveOpEvent> move(
|
||||||
|
Iterable<AvesEntry> entries, {
|
||||||
|
@required bool copy,
|
||||||
|
@required String destinationAlbum,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
'op': 'move',
|
'op': 'move',
|
||||||
|
@ -236,7 +254,25 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> rename(ImageEntry entry, String newName) async {
|
static Stream<ExportOpEvent> export(
|
||||||
|
Iterable<AvesEntry> entries, {
|
||||||
|
String mimeType = MimeTypes.jpeg,
|
||||||
|
@required String destinationAlbum,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
return opChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
'op': 'export',
|
||||||
|
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||||
|
'mimeType': mimeType,
|
||||||
|
'destinationPath': destinationAlbum,
|
||||||
|
}).map((event) => ExportOpEvent.fromMap(event));
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
return Stream.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map> rename(AvesEntry entry, String newName) async {
|
||||||
try {
|
try {
|
||||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||||
|
@ -250,7 +286,7 @@ class ImageFileService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
static Future<Map> rotate(AvesEntry entry, {@required bool clockwise}) async {
|
||||||
try {
|
try {
|
||||||
// return map with: 'rotationDegrees' 'isFlipped'
|
// return map with: 'rotationDegrees' 'isFlipped'
|
||||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||||
|
@ -264,7 +300,7 @@ class ImageFileService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> flip(ImageEntry entry) async {
|
static Future<Map> flip(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with: 'rotationDegrees' 'isFlipped'
|
// return map with: 'rotationDegrees' 'isFlipped'
|
||||||
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
final result = await platform.invokeMethod('flip', <String, dynamic>{
|
||||||
|
@ -278,57 +314,6 @@ class ImageFileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
|
||||||
class ImageOpEvent {
|
|
||||||
final bool success;
|
|
||||||
final String uri;
|
|
||||||
|
|
||||||
const ImageOpEvent({
|
|
||||||
this.success,
|
|
||||||
this.uri,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory ImageOpEvent.fromMap(Map map) {
|
|
||||||
return ImageOpEvent(
|
|
||||||
success: map['success'] ?? false,
|
|
||||||
uri: map['uri'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (other.runtimeType != runtimeType) return false;
|
|
||||||
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => hashValues(success, uri);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
|
||||||
}
|
|
||||||
|
|
||||||
class MoveOpEvent extends ImageOpEvent {
|
|
||||||
final Map newFields;
|
|
||||||
|
|
||||||
const MoveOpEvent({bool success, String uri, this.newFields})
|
|
||||||
: super(
|
|
||||||
success: success,
|
|
||||||
uri: uri,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory MoveOpEvent.fromMap(Map map) {
|
|
||||||
return MoveOpEvent(
|
|
||||||
success: map['success'] ?? false,
|
|
||||||
uri: map['uri'],
|
|
||||||
newFields: map['newFields'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
// cf flutter/foundation `consolidateHttpClientResponseBytes`
|
||||||
typedef BytesReceivedCallback = void Function(int cumulative, int total);
|
typedef BytesReceivedCallback = void Function(int cumulative, int total);
|
||||||
|
|
||||||
|
|
85
lib/services/image_op_events.dart
Normal file
85
lib/services/image_op_events.dart
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ImageOpEvent {
|
||||||
|
final bool success;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const ImageOpEvent({
|
||||||
|
this.success,
|
||||||
|
this.uri,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ImageOpEvent.fromMap(Map map) {
|
||||||
|
return ImageOpEvent(
|
||||||
|
success: map['success'] ?? false,
|
||||||
|
uri: map['uri'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other.runtimeType != runtimeType) return false;
|
||||||
|
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => hashValues(success, uri);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MoveOpEvent extends ImageOpEvent {
|
||||||
|
final Map newFields;
|
||||||
|
|
||||||
|
const MoveOpEvent({bool success, String uri, this.newFields})
|
||||||
|
: super(
|
||||||
|
success: success,
|
||||||
|
uri: uri,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory MoveOpEvent.fromMap(Map map) {
|
||||||
|
return MoveOpEvent(
|
||||||
|
success: map['success'] ?? false,
|
||||||
|
uri: map['uri'],
|
||||||
|
newFields: map['newFields'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportOpEvent extends MoveOpEvent {
|
||||||
|
final int pageId;
|
||||||
|
|
||||||
|
const ExportOpEvent({bool success, String uri, this.pageId, Map newFields})
|
||||||
|
: super(
|
||||||
|
success: success,
|
||||||
|
uri: uri,
|
||||||
|
newFields: newFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory ExportOpEvent.fromMap(Map map) {
|
||||||
|
return ExportOpEvent(
|
||||||
|
success: map['success'] ?? false,
|
||||||
|
uri: map['uri'],
|
||||||
|
pageId: map['pageId'],
|
||||||
|
newFields: map['newFields'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other.runtimeType != runtimeType) return false;
|
||||||
|
return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => hashValues(success, uri, pageId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}';
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/image_metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/multipage.dart';
|
import 'package:aves/model/multipage.dart';
|
||||||
import 'package:aves/model/panorama.dart';
|
import 'package:aves/model/panorama.dart';
|
||||||
import 'package:aves/services/service_policy.dart';
|
import 'package:aves/services/service_policy.dart';
|
||||||
|
@ -12,7 +12,7 @@ class MetadataService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/metadata');
|
static const platform = MethodChannel('deckers.thibault/aves/metadata');
|
||||||
|
|
||||||
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
|
||||||
static Future<Map> getAllMetadata(ImageEntry entry) async {
|
static Future<Map> getAllMetadata(AvesEntry entry) async {
|
||||||
if (entry.isSvg) return null;
|
if (entry.isSvg) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -28,7 +28,7 @@ class MetadataService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry, {bool background = false}) async {
|
static Future<CatalogMetadata> getCatalogMetadata(AvesEntry entry, {bool background = false}) async {
|
||||||
if (entry.isSvg) return null;
|
if (entry.isSvg) return null;
|
||||||
|
|
||||||
Future<CatalogMetadata> call() async {
|
Future<CatalogMetadata> call() async {
|
||||||
|
@ -65,7 +65,7 @@ class MetadataService {
|
||||||
: call();
|
: call();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {
|
static Future<OverlayMetadata> getOverlayMetadata(AvesEntry entry) async {
|
||||||
if (entry.isSvg) return null;
|
if (entry.isSvg) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -82,20 +82,21 @@ class MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<MultiPageInfo> getMultiPageInfo(ImageEntry entry) async {
|
static Future<MultiPageInfo> getMultiPageInfo(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
final result = await platform.invokeMethod('getMultiPageInfo', <String, dynamic>{
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
'uri': entry.uri,
|
'uri': entry.uri,
|
||||||
}) as Map;
|
});
|
||||||
return MultiPageInfo.fromMap(result);
|
final pageMaps = (result as List).cast<Map>();
|
||||||
|
return MultiPageInfo.fromPageMaps(pageMaps);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<PanoramaInfo> getPanoramaInfo(ImageEntry entry) async {
|
static Future<PanoramaInfo> getPanoramaInfo(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
// return map with values for:
|
// return map with values for:
|
||||||
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
// 'croppedAreaLeft' (int), 'croppedAreaTop' (int), 'croppedAreaWidth' (int), 'croppedAreaHeight' (int),
|
||||||
|
@ -112,6 +113,19 @@ class MetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||||
|
try {
|
||||||
|
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
|
||||||
|
'mimeType': entry.mimeType,
|
||||||
|
'uri': entry.uri,
|
||||||
|
'prop': prop,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
|
||||||
|
@ -124,7 +138,7 @@ class MetadataService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Uint8List>> getExifThumbnails(ImageEntry entry) async {
|
static Future<List<Uint8List>> getExifThumbnails(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
final result = await platform.invokeMethod('getExifThumbnails', <String, dynamic>{
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
|
@ -138,7 +152,7 @@ class MetadataService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath, String propMimeType) async {
|
static Future<Map> extractXmpDataProp(AvesEntry entry, String propPath, String propMimeType) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
|
||||||
'mimeType': entry.mimeType,
|
'mimeType': entry.mimeType,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/utils/string_utils.dart';
|
import 'package:aves/utils/string_utils.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
@ -15,9 +15,9 @@ class SvgMetadataService {
|
||||||
static const _textElements = ['title', 'desc'];
|
static const _textElements = ['title', 'desc'];
|
||||||
static const _metadataElement = 'metadata';
|
static const _metadataElement = 'metadata';
|
||||||
|
|
||||||
static Future<Size> getSize(ImageEntry entry) async {
|
static Future<Size> getSize(AvesEntry entry) async {
|
||||||
try {
|
try {
|
||||||
final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false);
|
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
|
||||||
|
|
||||||
final document = XmlDocument.parse(utf8.decode(data));
|
final document = XmlDocument.parse(utf8.decode(data));
|
||||||
final root = document.rootElement;
|
final root = document.rootElement;
|
||||||
|
@ -48,7 +48,7 @@ class SvgMetadataService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, Map<String, String>>> getAllMetadata(ImageEntry entry) async {
|
static Future<Map<String, Map<String, String>>> getAllMetadata(AvesEntry entry) async {
|
||||||
String formatKey(String key) {
|
String formatKey(String key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'desc':
|
case 'desc':
|
||||||
|
@ -59,7 +59,7 @@ class SvgMetadataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false);
|
final data = await ImageFileService.getSvg(entry.uri, entry.mimeType);
|
||||||
|
|
||||||
final document = XmlDocument.parse(utf8.decode(data));
|
final document = XmlDocument.parse(utf8.decode(data));
|
||||||
final root = document.rootElement;
|
final root = document.rootElement;
|
||||||
|
|
|
@ -48,4 +48,11 @@ class Durations {
|
||||||
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
|
||||||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||||
|
|
||||||
|
// Content change monitoring delay should be large enough,
|
||||||
|
// so that querying the Media Store yields final entries.
|
||||||
|
// For example, when taking a picture with a Galaxy S10e default camera app,
|
||||||
|
// querying the Media Store just 1 second after sometimes yields an entry with
|
||||||
|
// its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
|
||||||
|
static const contentChangeDebounceDelay = Duration(milliseconds: 1500);
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ class AIcons {
|
||||||
static const IconData createAlbum = Icons.add_circle_outline;
|
static const IconData createAlbum = Icons.add_circle_outline;
|
||||||
static const IconData debug = Icons.whatshot_outlined;
|
static const IconData debug = Icons.whatshot_outlined;
|
||||||
static const IconData delete = Icons.delete_outlined;
|
static const IconData delete = Icons.delete_outlined;
|
||||||
|
static const IconData export = Icons.save_alt_outlined;
|
||||||
static const IconData flip = Icons.flip_outlined;
|
static const IconData flip = Icons.flip_outlined;
|
||||||
static const IconData favourite = Icons.favorite_border;
|
static const IconData favourite = Icons.favorite_border;
|
||||||
static const IconData favouriteActive = Icons.favorite;
|
static const IconData favouriteActive = Icons.favorite;
|
||||||
|
@ -38,7 +39,7 @@ class AIcons {
|
||||||
static const IconData group = Icons.group_work_outlined;
|
static const IconData group = Icons.group_work_outlined;
|
||||||
static const IconData info = Icons.info_outlined;
|
static const IconData info = Icons.info_outlined;
|
||||||
static const IconData layers = Icons.layers_outlined;
|
static const IconData layers = Icons.layers_outlined;
|
||||||
static const IconData openInNew = Icons.open_in_new_outlined;
|
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||||
static const IconData pin = Icons.push_pin_outlined;
|
static const IconData pin = Icons.push_pin_outlined;
|
||||||
static const IconData print = Icons.print_outlined;
|
static const IconData print = Icons.print_outlined;
|
||||||
static const IconData refresh = Icons.refresh_outlined;
|
static const IconData refresh = Icons.refresh_outlined;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/android_file_service.dart';
|
import 'package:aves/services/android_file_service.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
|
@ -8,14 +9,17 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
class AndroidFileUtils {
|
class AndroidFileUtils {
|
||||||
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath;
|
||||||
Set<StorageVolume> storageVolumes = {};
|
Set<StorageVolume> storageVolumes = {};
|
||||||
Map appNameMap = {};
|
Set<Package> _packages = {};
|
||||||
|
List<String> _potentialAppDirs = [];
|
||||||
|
|
||||||
AChangeNotifier appNameChangeNotifier = AChangeNotifier();
|
AChangeNotifier appNameChangeNotifier = AChangeNotifier();
|
||||||
|
|
||||||
|
Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher);
|
||||||
|
|
||||||
AndroidFileUtils._private();
|
AndroidFileUtils._private();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
storageVolumes = (await AndroidFileService.getStorageVolumes()).map((map) => StorageVolume.fromMap(map)).toSet();
|
storageVolumes = await AndroidFileService.getStorageVolumes();
|
||||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||||
dcimPath = join(primaryStorage, 'DCIM');
|
dcimPath = join(primaryStorage, 'DCIM');
|
||||||
|
@ -25,8 +29,8 @@ class AndroidFileUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAppNames() async {
|
Future<void> initAppNames() async {
|
||||||
appNameMap = await AndroidAppService.getAppNames()
|
_packages = await AndroidAppService.getPackages();
|
||||||
..addAll({'KakaoTalkDownload': 'com.kakao.talk'});
|
_potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
|
||||||
appNameChangeNotifier.notifyListeners();
|
appNameChangeNotifier.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,27 +46,67 @@ class AndroidFileUtils {
|
||||||
|
|
||||||
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||||
|
|
||||||
AlbumType getAlbumType(String albumDirectory) {
|
AlbumType getAlbumType(String albumPath) {
|
||||||
if (albumDirectory != null) {
|
if (albumPath != null) {
|
||||||
if (isCameraPath(albumDirectory)) return AlbumType.camera;
|
if (isCameraPath(albumPath)) return AlbumType.camera;
|
||||||
if (isDownloadPath(albumDirectory)) return AlbumType.download;
|
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||||
if (isScreenRecordingsPath(albumDirectory)) return AlbumType.screenRecordings;
|
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||||
if (isScreenshotsPath(albumDirectory)) return AlbumType.screenshots;
|
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||||
|
|
||||||
final parts = albumDirectory.split(separator);
|
final dir = albumPath.split(separator).last;
|
||||||
if (albumDirectory.startsWith(primaryStorage) && appNameMap.keys.contains(parts.last)) return AlbumType.app;
|
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||||
}
|
}
|
||||||
return AlbumType.regular;
|
return AlbumType.regular;
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAlbumAppPackageName(String albumDirectory) {
|
String getAlbumAppPackageName(String albumPath) {
|
||||||
final parts = albumDirectory.split(separator);
|
if (albumPath == null) return null;
|
||||||
return appNameMap[parts.last];
|
final dir = albumPath.split(separator).last;
|
||||||
|
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
||||||
|
return package?.packageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getCurrentAppName(String packageName) {
|
||||||
|
final package = _packages.firstWhere((package) => package.packageName == packageName, orElse: () => null);
|
||||||
|
return package?.currentLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
|
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots }
|
||||||
|
|
||||||
|
class Package {
|
||||||
|
final String packageName, currentLabel, englishLabel;
|
||||||
|
final bool categoryLauncher, isSystem;
|
||||||
|
final Set<String> ownedDirs = {};
|
||||||
|
|
||||||
|
Package({
|
||||||
|
this.packageName,
|
||||||
|
this.currentLabel,
|
||||||
|
this.englishLabel,
|
||||||
|
this.categoryLauncher,
|
||||||
|
this.isSystem,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Package.fromMap(Map map) {
|
||||||
|
return Package(
|
||||||
|
packageName: map['packageName'],
|
||||||
|
currentLabel: map['currentLabel'],
|
||||||
|
englishLabel: map['englishLabel'],
|
||||||
|
categoryLauncher: map['categoryLauncher'],
|
||||||
|
isSystem: map['isSystem'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> get potentialDirs => [
|
||||||
|
currentLabel,
|
||||||
|
englishLabel,
|
||||||
|
...ownedDirs,
|
||||||
|
].where((dir) => dir != null).toSet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
|
||||||
|
}
|
||||||
|
|
||||||
class StorageVolume {
|
class StorageVolume {
|
||||||
final String description, path, state;
|
final String description, path, state;
|
||||||
final bool isEmulated, isPrimary, isRemovable;
|
final bool isEmulated, isPrimary, isRemovable;
|
||||||
|
|
|
@ -29,14 +29,14 @@ class Constants {
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'AndroidX Core-KTX',
|
name: 'AndroidX Core-KTX',
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
|
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core/core-ktx',
|
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'AndroidX Exifinterface',
|
name: 'AndroidX Exifinterface',
|
||||||
license: 'Apache 2.0',
|
license: 'Apache 2.0',
|
||||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/LICENSE.txt',
|
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/exifinterface/exifinterface',
|
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
|
||||||
),
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Android-TiffBitmapFactory',
|
name: 'Android-TiffBitmapFactory',
|
||||||
|
@ -83,18 +83,18 @@ class Constants {
|
||||||
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/dart-lang/collection',
|
sourceUrl: 'https://github.com/dart-lang/collection',
|
||||||
),
|
),
|
||||||
|
Dependency(
|
||||||
|
name: 'Connectivity',
|
||||||
|
license: 'BSD 3-Clause',
|
||||||
|
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity/LICENSE',
|
||||||
|
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/connectivity/connectivity',
|
||||||
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Decorated Icon',
|
name: 'Decorated Icon',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
|
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
|
||||||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||||
),
|
),
|
||||||
Dependency(
|
|
||||||
name: 'Draggable Scrollbar',
|
|
||||||
license: 'MIT',
|
|
||||||
licenseUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar/blob/master/LICENSE',
|
|
||||||
sourceUrl: 'https://github.com/fluttercommunity/flutter-draggable-scrollbar',
|
|
||||||
),
|
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'Event Bus',
|
name: 'Event Bus',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||||
import 'package:aves/widgets/common/gesture_area_protector.dart';
|
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
|
@ -2,11 +2,13 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/actions/collection_actions.dart';
|
import 'package:aves/model/actions/collection_actions.dart';
|
||||||
import 'package:aves/model/actions/entry_actions.dart';
|
import 'package:aves/model/actions/entry_actions.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||||
|
@ -22,7 +24,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
CollectionSource get source => collection.source;
|
CollectionSource get source => collection.source;
|
||||||
|
|
||||||
Set<ImageEntry> get selection => collection.selection;
|
Set<AvesEntry> get selection => collection.selection;
|
||||||
|
|
||||||
EntrySetActionDelegate({
|
EntrySetActionDelegate({
|
||||||
@required this.collection,
|
@required this.collection,
|
||||||
|
@ -46,10 +48,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case CollectionAction.copy:
|
case CollectionAction.copy:
|
||||||
_moveSelection(context, copy: true);
|
_moveSelection(context, moveType: MoveType.copy);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.move:
|
case CollectionAction.move:
|
||||||
_moveSelection(context, copy: false);
|
_moveSelection(context, moveType: MoveType.move);
|
||||||
break;
|
break;
|
||||||
case CollectionAction.refreshMetadata:
|
case CollectionAction.refreshMetadata:
|
||||||
source.refreshMetadata(selection);
|
source.refreshMetadata(selection);
|
||||||
|
@ -61,12 +63,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _moveSelection(BuildContext context, {@required bool copy}) async {
|
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
|
||||||
final destinationAlbum = await Navigator.push(
|
final destinationAlbum = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute<String>(
|
MaterialPageRoute<String>(
|
||||||
settings: RouteSettings(name: AlbumPickPage.routeName),
|
settings: RouteSettings(name: AlbumPickPage.routeName),
|
||||||
builder: (context) => AlbumPickPage(source: source, copy: copy),
|
builder: (context) => AlbumPickPage(source: source, moveType: moveType),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
if (destinationAlbum == null || destinationAlbum.isEmpty) return;
|
||||||
|
@ -74,16 +76,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
if (!await checkStoragePermission(context, selection)) return;
|
if (!await checkStoragePermission(context, selection)) return;
|
||||||
|
|
||||||
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, copy)) return;
|
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;
|
||||||
|
|
||||||
|
final copy = moveType == MoveType.copy;
|
||||||
|
final selectionCount = selection.length;
|
||||||
showOpReport<MoveOpEvent>(
|
showOpReport<MoveOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
selection: selection,
|
|
||||||
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
|
opStream: ImageFileService.move(selection, copy: copy, destinationAlbum: destinationAlbum),
|
||||||
|
itemCount: selectionCount,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final movedOps = processed.where((e) => e.success);
|
final movedOps = processed.where((e) => e.success);
|
||||||
final movedCount = movedOps.length;
|
final movedCount = movedOps.length;
|
||||||
final selectionCount = selection.length;
|
|
||||||
if (movedCount < selectionCount) {
|
if (movedCount < selectionCount) {
|
||||||
final count = selectionCount - movedCount;
|
final count = selectionCount - movedCount;
|
||||||
showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||||
|
@ -129,14 +132,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
if (!await checkStoragePermission(context, selection)) return;
|
if (!await checkStoragePermission(context, selection)) return;
|
||||||
|
|
||||||
|
final selectionCount = selection.length;
|
||||||
showOpReport<ImageOpEvent>(
|
showOpReport<ImageOpEvent>(
|
||||||
context: context,
|
context: context,
|
||||||
selection: selection,
|
|
||||||
opStream: ImageFileService.delete(selection),
|
opStream: ImageFileService.delete(selection),
|
||||||
|
itemCount: selectionCount,
|
||||||
onDone: (processed) {
|
onDone: (processed) {
|
||||||
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
|
final deletedUris = processed.where((e) => e.success).map((e) => e.uri).toList();
|
||||||
final deletedCount = deletedUris.length;
|
final deletedCount = deletedUris.length;
|
||||||
final selectionCount = selection.length;
|
|
||||||
if (deletedCount < selectionCount) {
|
if (deletedCount < selectionCount) {
|
||||||
final count = selectionCount - deletedCount;
|
final count = selectionCount - deletedCount;
|
||||||
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
showFeedback(context, 'Failed to delete ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||||
|
|
|
@ -25,7 +25,7 @@ class FilterBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FilterBarState extends State<FilterBar> {
|
class _FilterBarState extends State<FilterBar> {
|
||||||
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey();
|
final GlobalKey<AnimatedListState> _animatedListKey = GlobalKey(debugLabel: 'filter-bar-animated-list');
|
||||||
CollectionFilter _userRemovedFilter;
|
CollectionFilter _userRemovedFilter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/section_keys.dart';
|
import 'package:aves/model/source/section_keys.dart';
|
||||||
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
import 'package:aves/widgets/collection/grid/headers/any.dart';
|
||||||
|
@ -6,7 +6,7 @@ import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<ImageEntry> {
|
class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<AvesEntry> {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
|
|
||||||
const SectionedEntryListLayoutProvider({
|
const SectionedEntryListLayoutProvider({
|
||||||
|
@ -14,7 +14,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
||||||
@required double scrollableWidth,
|
@required double scrollableWidth,
|
||||||
@required int columnCount,
|
@required int columnCount,
|
||||||
@required double tileExtent,
|
@required double tileExtent,
|
||||||
@required Widget Function(ImageEntry entry) tileBuilder,
|
@required Widget Function(AvesEntry entry) tileBuilder,
|
||||||
@required Widget child,
|
@required Widget child,
|
||||||
}) : super(
|
}) : super(
|
||||||
scrollableWidth: scrollableWidth,
|
scrollableWidth: scrollableWidth,
|
||||||
|
@ -28,7 +28,7 @@ class SectionedEntryListLayoutProvider extends SectionedListLayoutProvider<Image
|
||||||
bool get showHeaders => collection.showHeaders;
|
bool get showHeaders => collection.showHeaders;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<SectionKey, List<ImageEntry>> get sections => collection.sections;
|
Map<SectionKey, List<AvesEntry>> get sections => collection.sections;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
double getHeaderExtent(BuildContext context, SectionKey sectionKey) {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/utils/math_utils.dart';
|
import 'package:aves/utils/math_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
@ -38,7 +39,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
||||||
|
|
||||||
CollectionLens get collection => widget.collection;
|
CollectionLens get collection => widget.collection;
|
||||||
|
|
||||||
List<ImageEntry> get entries => collection.sortedEntries;
|
List<AvesEntry> get entries => collection.sortedEntries;
|
||||||
|
|
||||||
ScrollController get scrollController => widget.scrollController;
|
ScrollController get scrollController => widget.scrollController;
|
||||||
|
|
||||||
|
@ -62,7 +63,7 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
||||||
_lastToIndex = _fromIndex;
|
_lastToIndex = _fromIndex;
|
||||||
_scrollableInsets = EdgeInsets.only(
|
_scrollableInsets = EdgeInsets.only(
|
||||||
top: appBarHeight,
|
top: appBarHeight,
|
||||||
bottom: context.read<MediaQueryData>().viewInsets.bottom,
|
bottom: context.read<MediaQueryData>().effectiveBottomPadding,
|
||||||
);
|
);
|
||||||
_scrollSpeedFactor = 0;
|
_scrollSpeedFactor = 0;
|
||||||
_pressing = true;
|
_pressing = true;
|
||||||
|
@ -130,12 +131,12 @@ class _GridSelectionGestureDetectorState extends State<GridSelectionGestureDetec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageEntry _getEntryAt(Offset localPosition) {
|
AvesEntry _getEntryAt(Offset localPosition) {
|
||||||
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
|
||||||
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
|
||||||
// so we use custom layout computation instead to find the entry.
|
// so we use custom layout computation instead to find the entry.
|
||||||
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
|
||||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||||
return sectionedListLayout.getItemAt(offset);
|
return sectionedListLayout.getItemAt(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/main.dart';
|
import 'package:aves/main.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/services/viewer_service.dart';
|
import 'package:aves/services/viewer_service.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
|
@ -10,7 +10,7 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class InteractiveThumbnail extends StatelessWidget {
|
class InteractiveThumbnail extends StatelessWidget {
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double tileExtent;
|
final double tileExtent;
|
||||||
final ValueNotifier<bool> isScrollingNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
|
|
||||||
|
@ -53,9 +53,15 @@ class InteractiveThumbnail extends StatelessWidget {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
TransparentMaterialPageRoute(
|
TransparentMaterialPageRoute(
|
||||||
settings: RouteSettings(name: MultiEntryViewerPage.routeName),
|
settings: RouteSettings(name: EntryViewerPage.routeName),
|
||||||
pageBuilder: (c, a, sa) => MultiEntryViewerPage(
|
pageBuilder: (c, a, sa) => EntryViewerPage(
|
||||||
collection: collection,
|
collection: CollectionLens(
|
||||||
|
source: collection.source,
|
||||||
|
filters: collection.filters,
|
||||||
|
groupFactor: collection.groupFactor,
|
||||||
|
sortFactor: collection.sortFactor,
|
||||||
|
listenToSource: false,
|
||||||
|
),
|
||||||
initialEntry: entry,
|
initialEntry: entry,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||||
|
@ -6,7 +6,7 @@ import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class DecoratedThumbnail extends StatelessWidget {
|
class DecoratedThumbnail extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final CollectionLens collection;
|
final CollectionLens collection;
|
||||||
final ValueNotifier<bool> isScrollingNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/utils/mime_utils.dart';
|
import 'package:aves/utils/mime_utils.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ErrorThumbnail extends StatelessWidget {
|
class ErrorThumbnail extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
|
@ -14,7 +14,7 @@ import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class ThumbnailEntryOverlay extends StatelessWidget {
|
class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
|
|
||||||
const ThumbnailEntryOverlay({
|
const ThumbnailEntryOverlay({
|
||||||
|
@ -61,7 +61,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailSelectionOverlay extends StatelessWidget {
|
class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
|
|
||||||
const ThumbnailSelectionOverlay({
|
const ThumbnailSelectionOverlay({
|
||||||
|
@ -121,7 +121,7 @@ class ThumbnailSelectionOverlay extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ThumbnailHighlightOverlay extends StatefulWidget {
|
class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
|
|
||||||
const ThumbnailHighlightOverlay({
|
const ThumbnailHighlightOverlay({
|
||||||
|
@ -137,7 +137,7 @@ class ThumbnailHighlightOverlay extends StatefulWidget {
|
||||||
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
||||||
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _highlightedNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -1,17 +1,14 @@
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
import 'package:aves/image_providers/thumbnail_provider.dart';
|
||||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry_images.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/error.dart';
|
import 'package:aves/widgets/collection/thumbnail/error.dart';
|
||||||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class RasterImageThumbnail extends StatefulWidget {
|
class RasterImageThumbnail extends StatefulWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final int page;
|
|
||||||
final ValueNotifier<bool> isScrollingNotifier;
|
final ValueNotifier<bool> isScrollingNotifier;
|
||||||
final Object heroTag;
|
final Object heroTag;
|
||||||
|
|
||||||
|
@ -19,7 +16,6 @@ class RasterImageThumbnail extends StatefulWidget {
|
||||||
Key key,
|
Key key,
|
||||||
@required this.entry,
|
@required this.entry,
|
||||||
@required this.extent,
|
@required this.extent,
|
||||||
this.page = 0,
|
|
||||||
this.isScrollingNotifier,
|
this.isScrollingNotifier,
|
||||||
this.heroTag,
|
this.heroTag,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
@ -31,19 +27,12 @@ class RasterImageThumbnail extends StatefulWidget {
|
||||||
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
ThumbnailProvider _fastThumbnailProvider, _sizedThumbnailProvider;
|
||||||
|
|
||||||
ImageEntry get entry => widget.entry;
|
AvesEntry get entry => widget.entry;
|
||||||
|
|
||||||
int get page => widget.page;
|
|
||||||
|
|
||||||
double get extent => widget.extent;
|
double get extent => widget.extent;
|
||||||
|
|
||||||
Object get heroTag => widget.heroTag;
|
Object get heroTag => widget.heroTag;
|
||||||
|
|
||||||
// we standardize the thumbnail loading dimension by taking the nearest larger power of 2
|
|
||||||
// so that there are less variants of the thumbnails to load and cache
|
|
||||||
// it increases the chance of cache hit when loading similarly sized columns (e.g. on orientation change)
|
|
||||||
double get requestExtent => pow(2, (log(extent) / log(2)).ceil()).toDouble();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -78,14 +67,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
void _initProvider() {
|
void _initProvider() {
|
||||||
if (!entry.canDecode) return;
|
if (!entry.canDecode) return;
|
||||||
|
|
||||||
_fastThumbnailProvider = ThumbnailProvider(
|
_fastThumbnailProvider = entry.getThumbnail();
|
||||||
ThumbnailProviderKey.fromEntry(entry, page: page),
|
_sizedThumbnailProvider = entry.getThumbnail(extent: extent);
|
||||||
);
|
|
||||||
if (!entry.isVideo) {
|
|
||||||
_sizedThumbnailProvider = ThumbnailProvider(
|
|
||||||
ThumbnailProviderKey.fromEntry(entry, page: page, extent: requestExtent),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pauseProvider() {
|
void _pauseProvider() {
|
||||||
|
@ -148,22 +131,8 @@ class _RasterImageThumbnailState extends State<RasterImageThumbnail> {
|
||||||
: Hero(
|
: Hero(
|
||||||
tag: heroTag,
|
tag: heroTag,
|
||||||
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
|
||||||
ImageProvider heroImageProvider = _fastThumbnailProvider;
|
|
||||||
if (!entry.isVideo) {
|
|
||||||
final imageProvider = UriImage(
|
|
||||||
uri: entry.uri,
|
|
||||||
mimeType: entry.mimeType,
|
|
||||||
page: page,
|
|
||||||
rotationDegrees: entry.rotationDegrees,
|
|
||||||
isFlipped: entry.isFlipped,
|
|
||||||
expectedContentLength: entry.sizeBytes,
|
|
||||||
);
|
|
||||||
if (imageCache.statusForKey(imageProvider).keepAlive) {
|
|
||||||
heroImageProvider = imageProvider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TransitionImage(
|
return TransitionImage(
|
||||||
image: heroImageProvider,
|
image: entry.getBestThumbnail(extent),
|
||||||
animation: animation,
|
animation: animation,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/image_providers/uri_picture_provider.dart';
|
import 'package:aves/image_providers/uri_picture_provider.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/settings/entry_background.dart';
|
import 'package:aves/model/settings/entry_background.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||||
|
@ -8,7 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class VectorImageThumbnail extends StatelessWidget {
|
class VectorImageThumbnail extends StatelessWidget {
|
||||||
final ImageEntry entry;
|
final AvesEntry entry;
|
||||||
final double extent;
|
final double extent;
|
||||||
final Object heroTag;
|
final Object heroTag;
|
||||||
|
|
||||||
|
@ -29,10 +29,10 @@ class VectorImageThumbnail extends StatelessWidget {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final availableSize = constraints.biggest;
|
final availableSize = constraints.biggest;
|
||||||
final fitSize = applyBoxFit(fit, entry.getDisplaySize(), availableSize).destination;
|
final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination;
|
||||||
final offset = fitSize / 2 - availableSize / 2;
|
final offset = fitSize / 2 - availableSize / 2;
|
||||||
final child = DecoratedBox(
|
final child = CustomPaint(
|
||||||
decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset),
|
painter: CheckeredPainter(checkSize: extent / 8, offset: offset),
|
||||||
child: SvgPicture(
|
child: SvgPicture(
|
||||||
UriPicture(
|
UriPicture(
|
||||||
uri: entry.uri,
|
uri: entry.uri,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/main.dart';
|
||||||
import 'package:aves/model/filters/favourite.dart';
|
import 'package:aves/model/filters/favourite.dart';
|
||||||
import 'package:aves/model/filters/mime.dart';
|
import 'package:aves/model/filters/mime.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
@ -16,15 +16,17 @@ import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||||
import 'package:aves/widgets/collection/grid/selector.dart';
|
import 'package:aves/widgets/collection/grid/selector.dart';
|
||||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/insets.dart';
|
||||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||||
import 'package:aves/widgets/common/grid/section_layout.dart';
|
import 'package:aves/widgets/common/grid/section_layout.dart';
|
||||||
import 'package:aves/widgets/common/grid/sliver.dart';
|
import 'package:aves/widgets/common/grid/sliver.dart';
|
||||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||||
import 'package:aves/widgets/common/scaling.dart';
|
import 'package:aves/widgets/common/scaling.dart';
|
||||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
@ -34,7 +36,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||||
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
|
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
|
||||||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||||
final GlobalKey _scrollableKey = GlobalKey();
|
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
|
||||||
|
|
||||||
static const columnCountDefault = 4;
|
static const columnCountDefault = 4;
|
||||||
static const extentMin = 46.0;
|
static const extentMin = 46.0;
|
||||||
|
@ -44,6 +46,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return HighlightInfoProvider(
|
return HighlightInfoProvider(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final viewportSize = constraints.biggest;
|
final viewportSize = constraints.biggest;
|
||||||
|
@ -79,7 +82,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
final scaler = GridScaleGestureDetector<AvesEntry>(
|
||||||
tileExtentManager: tileExtentManager,
|
tileExtentManager: tileExtentManager,
|
||||||
scrollableKey: _scrollableKey,
|
scrollableKey: _scrollableKey,
|
||||||
appBarHeightNotifier: _appBarHeightNotifier,
|
appBarHeightNotifier: _appBarHeightNotifier,
|
||||||
|
@ -103,7 +106,7 @@ class ThumbnailCollection extends StatelessWidget {
|
||||||
highlightable: false,
|
highlightable: false,
|
||||||
),
|
),
|
||||||
getScaledItemTileRect: (context, entry) {
|
getScaledItemTileRect: (context, entry) {
|
||||||
final sectionedListLayout = context.read<SectionedListLayout<ImageEntry>>();
|
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
|
||||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||||
},
|
},
|
||||||
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
onScaled: (entry) => Provider.of<HighlightInfo>(context, listen: false).add(entry),
|
||||||
|
@ -192,10 +195,12 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(CollectionScrollView widget) {
|
void _registerWidget(CollectionScrollView widget) {
|
||||||
|
widget.collection.filterChangeNotifier.addListener(_onFilterChange);
|
||||||
widget.scrollController.addListener(_onScrollChange);
|
widget.scrollController.addListener(_onScrollChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(CollectionScrollView widget) {
|
void _unregisterWidget(CollectionScrollView widget) {
|
||||||
|
widget.collection.filterChangeNotifier.removeListener(_onFilterChange);
|
||||||
widget.scrollController.removeListener(_onScrollChange);
|
widget.scrollController.removeListener(_onScrollChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,15 +225,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
||||||
child: _buildEmptyCollectionPlaceholder(collection),
|
child: _buildEmptyCollectionPlaceholder(collection),
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
)
|
)
|
||||||
: SectionedListSliver<ImageEntry>(),
|
: SectionedListSliver<AvesEntry>(),
|
||||||
SliverToBoxAdapter(
|
BottomPaddingSliver(),
|
||||||
child: Selector<MediaQueryData, double>(
|
|
||||||
selector: (context, mq) => mq.viewInsets.bottom,
|
|
||||||
builder: (context, mqViewInsetsBottom, child) {
|
|
||||||
return SizedBox(height: mqViewInsetsBottom);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -237,10 +235,10 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
||||||
return ValueListenableBuilder<double>(
|
return ValueListenableBuilder<double>(
|
||||||
valueListenable: widget.appBarHeightNotifier,
|
valueListenable: widget.appBarHeightNotifier,
|
||||||
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
|
builder: (context, appBarHeight, child) => Selector<MediaQueryData, double>(
|
||||||
selector: (context, mq) => mq.viewInsets.bottom,
|
selector: (context, mq) => mq.effectiveBottomPadding,
|
||||||
builder: (context, mqViewInsetsBottom, child) => DraggableScrollbar(
|
builder: (context, mqPaddingBottom, child) => DraggableScrollbar(
|
||||||
heightScrollThumb: avesScrollThumbHeight,
|
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
|
scrollThumbHeight: avesScrollThumbHeight,
|
||||||
scrollThumbBuilder: avesScrollThumbBuilder(
|
scrollThumbBuilder: avesScrollThumbBuilder(
|
||||||
height: avesScrollThumbHeight,
|
height: avesScrollThumbHeight,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
|
@ -249,7 +247,7 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
// padding to keep scroll thumb between app bar above and nav bar below
|
// padding to keep scroll thumb between app bar above and nav bar below
|
||||||
top: appBarHeight,
|
top: appBarHeight,
|
||||||
bottom: mqViewInsetsBottom,
|
bottom: mqPaddingBottom,
|
||||||
),
|
),
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
),
|
),
|
||||||
|
@ -285,6 +283,8 @@ class _CollectionScrollViewState extends State<CollectionScrollView> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onFilterChange() => widget.scrollController.jumpTo(0);
|
||||||
|
|
||||||
void _onScrollChange() {
|
void _onScrollChange() {
|
||||||
widget.isScrollingNotifier.value = true;
|
widget.isScrollingNotifier.value = true;
|
||||||
_stopScrollMonitoringTimer();
|
_stopScrollMonitoringTimer();
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
|
||||||
import 'package:aves/services/image_file_service.dart';
|
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:flushbar/flushbar.dart';
|
import 'package:flushbar/flushbar.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -27,19 +25,66 @@ mixin FeedbackMixin {
|
||||||
|
|
||||||
// report overlay for multiple operations
|
// report overlay for multiple operations
|
||||||
|
|
||||||
OverlayEntry _opReportOverlayEntry;
|
void showOpReport<T>({
|
||||||
|
|
||||||
void showOpReport<T extends ImageOpEvent>({
|
|
||||||
@required BuildContext context,
|
@required BuildContext context,
|
||||||
@required Set<ImageEntry> selection,
|
|
||||||
@required Stream<T> opStream,
|
@required Stream<T> opStream,
|
||||||
@required void Function(Set<T> processed) onDone,
|
@required int itemCount,
|
||||||
|
void Function(Set<T> processed) onDone,
|
||||||
}) {
|
}) {
|
||||||
|
OverlayEntry _opReportOverlayEntry;
|
||||||
|
_opReportOverlayEntry = OverlayEntry(
|
||||||
|
builder: (context) => ReportOverlay<T>(
|
||||||
|
opStream: opStream,
|
||||||
|
itemCount: itemCount,
|
||||||
|
onDone: (processed) {
|
||||||
|
_opReportOverlayEntry.remove();
|
||||||
|
onDone?.call(processed);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Overlay.of(context).insert(_opReportOverlayEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReportOverlay<T> extends StatefulWidget {
|
||||||
|
final Stream<T> opStream;
|
||||||
|
final int itemCount;
|
||||||
|
final void Function(Set<T> processed) onDone;
|
||||||
|
|
||||||
|
const ReportOverlay({
|
||||||
|
@required this.opStream,
|
||||||
|
@required this.itemCount,
|
||||||
|
@required this.onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ReportOverlayState createState() => _ReportOverlayState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerProviderStateMixin {
|
||||||
final processed = <T>{};
|
final processed = <T>{};
|
||||||
|
AnimationController _animationController;
|
||||||
|
Animation<double> _animation;
|
||||||
|
|
||||||
|
Stream<T> get opStream => widget.opStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_animationController = AnimationController(
|
||||||
|
duration: Durations.collectionOpOverlayAnimation,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animation = CurvedAnimation(
|
||||||
|
parent: _animationController,
|
||||||
|
curve: Curves.easeOutQuad,
|
||||||
|
);
|
||||||
|
_animationController.forward();
|
||||||
|
|
||||||
// do not handle completion inside `StreamBuilder`
|
// do not handle completion inside `StreamBuilder`
|
||||||
// as it could be called multiple times
|
// as it could be called multiple times
|
||||||
Future<void> onComplete() => _hideOpReportOverlay().then((_) => onDone(processed));
|
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
|
||||||
opStream.listen(
|
opStream.listen(
|
||||||
processed.add,
|
processed.add,
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
|
@ -48,17 +93,34 @@ mixin FeedbackMixin {
|
||||||
},
|
},
|
||||||
onDone: onComplete,
|
onDone: onComplete,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_opReportOverlayEntry = OverlayEntry(
|
@override
|
||||||
builder: (context) {
|
void dispose() {
|
||||||
|
_animationController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return AbsorbPointer(
|
return AbsorbPointer(
|
||||||
child: StreamBuilder<T>(
|
child: StreamBuilder<T>(
|
||||||
stream: opStream,
|
stream: opStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
Widget child = SizedBox.shrink();
|
final percent = processed.length.toDouble() / widget.itemCount;
|
||||||
if (!snapshot.hasError) {
|
return FadeTransition(
|
||||||
final percent = processed.length.toDouble() / selection.length;
|
opacity: _animation,
|
||||||
child = CircularPercentIndicator(
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.black,
|
||||||
|
Colors.black54,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: CircularPercentIndicator(
|
||||||
percent: percent,
|
percent: percent,
|
||||||
lineWidth: 16,
|
lineWidth: 16,
|
||||||
radius: 160,
|
radius: 160,
|
||||||
|
@ -67,22 +129,11 @@ mixin FeedbackMixin {
|
||||||
animation: true,
|
animation: true,
|
||||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||||
animateFromLastPercent: true,
|
animateFromLastPercent: true,
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
return AnimatedSwitcher(
|
),
|
||||||
duration: Durations.collectionOpOverlayAnimation,
|
|
||||||
child: child,
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
Overlay.of(context).insert(_opReportOverlayEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _hideOpReportOverlay() async {
|
|
||||||
await Future.delayed(Durations.collectionOpOverlayAnimation * timeDilation);
|
|
||||||
_opReportOverlayEntry.remove();
|
|
||||||
_opReportOverlayEntry = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/android_file_service.dart';
|
import 'package:aves/services/android_file_service.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
mixin PermissionAwareMixin {
|
mixin PermissionAwareMixin {
|
||||||
Future<bool> checkStoragePermission(BuildContext context, Set<ImageEntry> entries) {
|
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
|
||||||
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
return checkStoragePermissionForAlbums(context, entries.where((e) => e.path != null).map((e) => e.directory).toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/image_entry.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/android_file_service.dart';
|
import 'package:aves/services/android_file_service.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
|
@ -11,21 +12,30 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
mixin SizeAwareMixin {
|
mixin SizeAwareMixin {
|
||||||
Future<bool> checkFreeSpaceForMove(BuildContext context, Set<ImageEntry> selection, String destinationAlbum, bool copy) async {
|
Future<bool> checkFreeSpaceForMove(
|
||||||
|
BuildContext context,
|
||||||
|
Set<AvesEntry> selection,
|
||||||
|
String destinationAlbum,
|
||||||
|
MoveType moveType,
|
||||||
|
) async {
|
||||||
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||||
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
final free = await AndroidFileService.getFreeSpace(destinationVolume);
|
||||||
int needed;
|
int needed;
|
||||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||||
if (copy) {
|
switch (moveType) {
|
||||||
|
case MoveType.copy:
|
||||||
|
case MoveType.export:
|
||||||
needed = selection.fold(0, sumSize);
|
needed = selection.fold(0, sumSize);
|
||||||
} else {
|
break;
|
||||||
|
case MoveType.move:
|
||||||
// when moving, we only need space for the entries that are not already on the destination volume
|
// when moving, we only need space for the entries that are not already on the destination volume
|
||||||
final byVolume = groupBy<ImageEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
final byVolume = groupBy<AvesEntry, StorageVolume>(selection, (entry) => androidFileUtils.getStorageVolume(entry.path));
|
||||||
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
final otherVolumes = byVolume.keys.where((volume) => volume != destinationVolume);
|
||||||
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
final fromOtherVolumes = otherVolumes.fold<int>(0, (sum, volume) => sum + byVolume[volume].fold(0, sumSize));
|
||||||
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
// and we need at least as much space as the largest entry because individual entries are copied then deleted
|
||||||
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
final largestSingle = selection.fold<int>(0, (largest, entry) => max(largest, entry.sizeBytes));
|
||||||
needed = max(fromOtherVolumes, largestSingle);
|
needed = max(fromOtherVolumes, largestSingle);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasEnoughSpace = needed < free;
|
final hasEnoughSpace = needed < free;
|
||||||
|
|
388
lib/widgets/common/basic/draggable_scrollbar.dart
Normal file
388
lib/widgets/common/basic/draggable_scrollbar.dart
Normal file
|
@ -0,0 +1,388 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/*
|
||||||
|
This is derived from `draggable_scrollbar` package v0.0.4:
|
||||||
|
- removed default thumb builders
|
||||||
|
- allow any `ScrollView` as child
|
||||||
|
- allow any `Widget` as label content
|
||||||
|
- moved out constraints responsibility
|
||||||
|
- various extent & thumb positioning fixes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// Build the Scroll Thumb and label using the current configuration
|
||||||
|
typedef ScrollThumbBuilder = Widget Function(
|
||||||
|
Color backgroundColor,
|
||||||
|
Animation<double> thumbAnimation,
|
||||||
|
Animation<double> labelAnimation,
|
||||||
|
double height, {
|
||||||
|
Widget labelText,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Build a Text widget using the current scroll offset
|
||||||
|
typedef LabelTextBuilder = Widget Function(double offsetY);
|
||||||
|
|
||||||
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
|
/// for quick navigation of the BoxScrollView.
|
||||||
|
class DraggableScrollbar extends StatefulWidget {
|
||||||
|
/// The background color of the label and thumb
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// The height of the scroll thumb
|
||||||
|
final double scrollThumbHeight;
|
||||||
|
|
||||||
|
/// A function that builds a thumb using the current configuration
|
||||||
|
final ScrollThumbBuilder scrollThumbBuilder;
|
||||||
|
|
||||||
|
/// The amount of padding that should surround the thumb
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
/// Determines how quickly the scrollbar will animate in and out
|
||||||
|
final Duration scrollbarAnimationDuration;
|
||||||
|
|
||||||
|
/// How long should the thumb be visible before fading out
|
||||||
|
final Duration scrollbarTimeToFade;
|
||||||
|
|
||||||
|
/// Build a Text widget from the current offset in the BoxScrollView
|
||||||
|
final LabelTextBuilder labelTextBuilder;
|
||||||
|
|
||||||
|
/// The ScrollController for the BoxScrollView
|
||||||
|
final ScrollController controller;
|
||||||
|
|
||||||
|
/// The view that will be scrolled with the scroll thumb
|
||||||
|
final ScrollView child;
|
||||||
|
|
||||||
|
DraggableScrollbar({
|
||||||
|
Key key,
|
||||||
|
@required this.backgroundColor,
|
||||||
|
@required this.scrollThumbHeight,
|
||||||
|
@required this.scrollThumbBuilder,
|
||||||
|
@required this.controller,
|
||||||
|
this.padding,
|
||||||
|
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||||
|
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||||
|
this.labelTextBuilder,
|
||||||
|
@required this.child,
|
||||||
|
}) : assert(controller != null),
|
||||||
|
assert(scrollThumbBuilder != null),
|
||||||
|
assert(child.scrollDirection == Axis.vertical),
|
||||||
|
super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_DraggableScrollbarState createState() => _DraggableScrollbarState();
|
||||||
|
|
||||||
|
static Widget buildScrollThumbAndLabel({
|
||||||
|
@required Widget scrollThumb,
|
||||||
|
@required Color backgroundColor,
|
||||||
|
@required Animation<double> thumbAnimation,
|
||||||
|
@required Animation<double> labelAnimation,
|
||||||
|
@required Widget labelText,
|
||||||
|
}) {
|
||||||
|
final scrollThumbAndLabel = labelText == null
|
||||||
|
? scrollThumb
|
||||||
|
: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ScrollLabel(
|
||||||
|
animation: labelAnimation,
|
||||||
|
child: labelText,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
),
|
||||||
|
scrollThumb,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return SlideFadeTransition(
|
||||||
|
animation: thumbAnimation,
|
||||||
|
child: scrollThumbAndLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollLabel extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ScrollLabel({
|
||||||
|
Key key,
|
||||||
|
@required this.child,
|
||||||
|
@required this.animation,
|
||||||
|
@required this.backgroundColor,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.only(right: 12.0),
|
||||||
|
child: Material(
|
||||||
|
elevation: 4.0,
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(16.0)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
|
||||||
|
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0), _viewOffsetNotifier = ValueNotifier(0);
|
||||||
|
bool _isDragInProcess = false;
|
||||||
|
|
||||||
|
AnimationController _thumbAnimationController;
|
||||||
|
Animation<double> _thumbAnimation;
|
||||||
|
AnimationController _labelAnimationController;
|
||||||
|
Animation<double> _labelAnimation;
|
||||||
|
Timer _fadeoutTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
_thumbAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_thumbAnimation = CurvedAnimation(
|
||||||
|
parent: _thumbAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimationController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: widget.scrollbarAnimationDuration,
|
||||||
|
);
|
||||||
|
|
||||||
|
_labelAnimation = CurvedAnimation(
|
||||||
|
parent: _labelAnimationController,
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_thumbAnimationController.dispose();
|
||||||
|
_labelAnimationController.dispose();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollController get controller => widget.controller;
|
||||||
|
|
||||||
|
double get thumbMaxScrollExtent => context.size.height - widget.scrollThumbHeight - (widget.padding?.vertical ?? 0.0);
|
||||||
|
|
||||||
|
double get thumbMinScrollExtent => 0.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
_onScrollNotification(notification);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
RepaintBoundary(
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragStart: _onVerticalDragStart,
|
||||||
|
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||||
|
onVerticalDragEnd: _onVerticalDragEnd,
|
||||||
|
child: ValueListenableBuilder(
|
||||||
|
valueListenable: _thumbOffsetNotifier,
|
||||||
|
builder: (context, thumbOffset, child) => Container(
|
||||||
|
alignment: AlignmentDirectional.topEnd,
|
||||||
|
padding: EdgeInsets.only(top: thumbOffset) + widget.padding,
|
||||||
|
child: widget.scrollThumbBuilder(
|
||||||
|
widget.backgroundColor,
|
||||||
|
_thumbAnimation,
|
||||||
|
_labelAnimation,
|
||||||
|
widget.scrollThumbHeight,
|
||||||
|
labelText: (widget.labelTextBuilder != null && _isDragInProcess)
|
||||||
|
? ValueListenableBuilder(
|
||||||
|
valueListenable: _viewOffsetNotifier,
|
||||||
|
builder: (context, viewOffset, child) => widget.labelTextBuilder(viewOffset + thumbOffset),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScrollNotification(ScrollNotification notification) {
|
||||||
|
final scrollMetrics = notification.metrics;
|
||||||
|
|
||||||
|
// do not update the thumb if we cannot actually scroll
|
||||||
|
if (scrollMetrics.minScrollExtent >= scrollMetrics.maxScrollExtent) return;
|
||||||
|
|
||||||
|
_viewOffsetNotifier.value = scrollMetrics.pixels;
|
||||||
|
|
||||||
|
// we update the thumb position from the scrolled offset
|
||||||
|
// when the user is not dragging the thumb
|
||||||
|
if (!_isDragInProcess) {
|
||||||
|
if (notification is ScrollUpdateNotification) {
|
||||||
|
_thumbOffsetNotifier.value = (scrollMetrics.pixels / scrollMetrics.maxScrollExtent * thumbMaxScrollExtent).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
|
||||||
|
_showThumb();
|
||||||
|
_scheduleFadeout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragStart(DragStartDetails details) {
|
||||||
|
_labelAnimationController.forward();
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
setState(() => _isDragInProcess = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
_showThumb();
|
||||||
|
if (_isDragInProcess) {
|
||||||
|
// thumb offset
|
||||||
|
_thumbOffsetNotifier.value = (_thumbOffsetNotifier.value + details.delta.dy).clamp(thumbMinScrollExtent, thumbMaxScrollExtent);
|
||||||
|
|
||||||
|
// scroll offset
|
||||||
|
final min = controller.position.minScrollExtent;
|
||||||
|
final max = controller.position.maxScrollExtent;
|
||||||
|
controller.jumpTo((_thumbOffsetNotifier.value / thumbMaxScrollExtent * max).clamp(min, max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVerticalDragEnd(DragEndDetails details) {
|
||||||
|
_scheduleFadeout();
|
||||||
|
setState(() => _isDragInProcess = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showThumb() {
|
||||||
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
|
_thumbAnimationController.forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleFadeout() {
|
||||||
|
_fadeoutTimer?.cancel();
|
||||||
|
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||||
|
_thumbAnimationController.reverse();
|
||||||
|
_labelAnimationController.reverse();
|
||||||
|
_fadeoutTimer = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws 2 triangles like arrow up and arrow down
|
||||||
|
class ArrowCustomPainter extends CustomPainter {
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
ArrowCustomPainter(this.color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = color;
|
||||||
|
const width = 12.0;
|
||||||
|
const height = 8.0;
|
||||||
|
final baseX = size.width / 2;
|
||||||
|
final baseY = size.height / 2;
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
canvas.drawPath(
|
||||||
|
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||||
|
return Path()
|
||||||
|
..moveTo(o.dx, o.dy)
|
||||||
|
..lineTo(o.dx + width, o.dy)
|
||||||
|
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||||
|
..close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///This cut 2 lines in arrow shape
|
||||||
|
class ArrowClipper extends CustomClipper<Path> {
|
||||||
|
@override
|
||||||
|
Path getClip(Size size) {
|
||||||
|
final path = Path();
|
||||||
|
path.lineTo(0.0, size.height);
|
||||||
|
path.lineTo(size.width, size.height);
|
||||||
|
path.lineTo(size.width, 0.0);
|
||||||
|
path.lineTo(0.0, 0.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
const arrowWidth = 8.0;
|
||||||
|
final startPointX = (size.width - arrowWidth) / 2;
|
||||||
|
var startPointY = size.height / 2 - arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
|
||||||
|
path.lineTo(startPointX, startPointY + 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
startPointY = size.height / 2 + arrowWidth / 2;
|
||||||
|
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||||
|
path.lineTo(startPointX, startPointY);
|
||||||
|
path.lineTo(startPointX, startPointY - 1.0);
|
||||||
|
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
|
||||||
|
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlideFadeTransition extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const SlideFadeTransition({
|
||||||
|
Key key,
|
||||||
|
@required this.animation,
|
||||||
|
@required this.child,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) => animation.value == 0.0 ? Container() : child,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: Offset(0.3, 0.0),
|
||||||
|
end: Offset(0.0, 0.0),
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
62
lib/widgets/common/basic/insets.dart
Normal file
62
lib/widgets/common/basic/insets.dart
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
// This widget should be added on top of Scaffolds with:
|
||||||
|
// - `resizeToAvoidBottomInset` set to false,
|
||||||
|
// - a vertically scrollable body.
|
||||||
|
// It will prevent the body from scrolling when a user swipe from bottom to use Android Q style navigation gestures.
|
||||||
|
class BottomGestureAreaProtector extends StatelessWidget {
|
||||||
|
// as of Flutter v1.22.5, `systemGestureInsets` from `MediaQuery` mistakenly reports no bottom inset,
|
||||||
|
// so we use an empirical measurement instead
|
||||||
|
static const double systemGestureInsetsBottom = 32;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Selector<MediaQueryData, double>(
|
||||||
|
selector: (c, mq) => mq.effectiveBottomPadding,
|
||||||
|
builder: (c, mqPaddingBottom, child) {
|
||||||
|
// devices with physical navigation buttons have no bottom insets
|
||||||
|
// we assume these devices do not use gesture navigation
|
||||||
|
if (mqPaddingBottom == 0) return SizedBox();
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: systemGestureInsetsBottom,
|
||||||
|
child: AbsorbPointer(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GestureAreaProtectorStack extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const GestureAreaProtectorStack({@required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
BottomGestureAreaProtector(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BottomPaddingSliver extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Selector<MediaQueryData, double>(
|
||||||
|
selector: (context, mq) => mq.effectiveBottomPadding,
|
||||||
|
builder: (context, mqPaddingBottom, child) {
|
||||||
|
return SizedBox(height: mqPaddingBottom);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,7 +44,7 @@ class LinkChip extends StatelessWidget {
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) => Icon(
|
builder: (context) => Icon(
|
||||||
AIcons.openInNew,
|
AIcons.openOutside,
|
||||||
size: DefaultTextStyle.of(context).style.fontSize,
|
size: DefaultTextStyle.of(context).style.fontSize,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
extension ExtraContext on BuildContext {
|
|
||||||
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
|
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
DirectMaterialPageRoute({
|
DirectMaterialPageRoute({
|
||||||
RouteSettings settings,
|
RouteSettings settings,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue