Merge commit '514b4ce0f0943052b877419fced578214207ed8a'
This commit is contained in:
commit
ea9a271f36
124 changed files with 2505 additions and 1238 deletions
|
@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.4.9] - 2021-08-20
|
||||
### Added
|
||||
- Map & Stats from selection
|
||||
- Map: item browsing, rotation control
|
||||
- Navigation menu customization
|
||||
- shortcut support on older devices (API < 26)
|
||||
- support Android 12/S (API 31)
|
||||
|
||||
## [v1.4.8] - 2021-08-08
|
||||
### Added
|
||||
- Map
|
||||
|
|
|
@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
|
||||
- favorites
|
||||
- statistics
|
||||
- support Android API 20 ~ 30 (Lollipop ~ R)
|
||||
- support Android API 20 ~ 31 (Lollipop ~ S)
|
||||
- Android integration (app shortcuts, handle view/pick intents)
|
||||
|
||||
## Project Setup
|
||||
|
@ -29,4 +29,4 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
|
|||
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys.
|
||||
|
||||
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Release%20on%20tag
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||
|
|
|
@ -43,7 +43,7 @@ if (keystorePropertiesFile.exists()) {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdkVersion 31
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
@ -60,7 +60,7 @@ android {
|
|||
// - google_maps_flutter v2.0.5: 20
|
||||
// - Aves native: 19
|
||||
minSdkVersion 20
|
||||
targetSdkVersion 30
|
||||
targetSdkVersion 31
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
|
||||
|
@ -115,7 +115,7 @@ repositories {
|
|||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.2'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.3'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
|
|
|
@ -30,6 +30,9 @@
|
|||
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- for API < 26 -->
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
|
||||
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
|
||||
<queries>
|
||||
<intent>
|
||||
|
|
|
@ -119,7 +119,9 @@ class MainActivity : FlutterActivity() {
|
|||
when (requestCode) {
|
||||
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode)
|
||||
DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode)
|
||||
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onPermissionResult(requestCode, data?.data)
|
||||
CREATE_FILE_REQUEST,
|
||||
OPEN_FILE_REQUEST,
|
||||
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,7 +129,7 @@ class MainActivity : FlutterActivity() {
|
|||
private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
|
||||
val treeUri = data?.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
onPermissionResult(requestCode, null)
|
||||
onStorageAccessResult(requestCode, null)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -138,7 +140,7 @@ class MainActivity : FlutterActivity() {
|
|||
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
|
||||
|
||||
// resume pending action
|
||||
onPermissionResult(requestCode, treeUri)
|
||||
onStorageAccessResult(requestCode, treeUri)
|
||||
}
|
||||
|
||||
private fun onDeletePermissionResult(resultCode: Int) {
|
||||
|
@ -152,9 +154,17 @@ class MainActivity : FlutterActivity() {
|
|||
when (intent?.action) {
|
||||
Intent.ACTION_MAIN -> {
|
||||
intent.getStringExtra("page")?.let { page ->
|
||||
var filters = intent.getStringArrayExtra("filters")?.toList()
|
||||
if (filters == null) {
|
||||
// fallback for shortcuts created on API < 26
|
||||
val filterString = intent.getStringExtra("filtersString")
|
||||
if (filterString != null) {
|
||||
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
|
||||
}
|
||||
}
|
||||
return hashMapOf(
|
||||
"page" to page,
|
||||
"filters" to intent.getStringArrayExtra("filters")?.toList(),
|
||||
"filters" to filters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -209,9 +219,13 @@ class MainActivity : FlutterActivity() {
|
|||
private fun setupShortcuts() {
|
||||
// do not use 'route' as extra key, as the Flutter framework acts on it
|
||||
|
||||
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
|
||||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search")
|
||||
|
@ -220,7 +234,7 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
|
@ -234,18 +248,19 @@ class MainActivity : FlutterActivity() {
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MainActivity>()
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
|
||||
const val DOCUMENT_TREE_ACCESS_REQUEST = 1
|
||||
const val DELETE_PERMISSION_REQUEST = 2
|
||||
const val CREATE_FILE_REQUEST = 3
|
||||
const val OPEN_FILE_REQUEST = 4
|
||||
const val SELECT_DIRECTORY_REQUEST = 5
|
||||
|
||||
// permission request code to pending runnable
|
||||
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>()
|
||||
// request code to pending runnable
|
||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||
|
||||
fun onPermissionResult(requestCode: Int, uri: Uri?) {
|
||||
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, uri=$uri")
|
||||
val handler = pendingResultHandlers.remove(requestCode) ?: return
|
||||
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
|
||||
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
|
||||
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
|
||||
if (uri != null) {
|
||||
handler.onGranted(uri)
|
||||
} else {
|
||||
|
@ -261,4 +276,4 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
// onGranted: user selected a directory/file (with no guarantee that it matches the requested `path`)
|
||||
// onDenied: user cancelled
|
||||
data class PendingResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)
|
||||
data class PendingStorageAccessResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)
|
||||
|
|
|
@ -46,8 +46,12 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
|
||||
val matrixCursor = MatrixCursor(columns)
|
||||
context?.let { context ->
|
||||
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
|
||||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
val searchShortcutTitle = "${context.resources.getString(R.string.search_shortcut_short_label)} $query"
|
||||
val searchShortcutIcon = context.resourceUri(R.mipmap.ic_shortcut_search)
|
||||
val searchShortcutIcon = context.resourceUri(if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search)
|
||||
matrixCursor.addRow(arrayOf(null, null, null, searchShortcutTitle, null, searchShortcutIcon))
|
||||
|
||||
runBlocking {
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
|
|||
import android.content.*
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
@ -103,27 +104,29 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
var data: ByteArray? = null
|
||||
try {
|
||||
val iconResourceId = context.packageManager.getApplicationInfo(packageName, 0).icon
|
||||
val uri = Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(packageName)
|
||||
.path(iconResourceId.toString())
|
||||
.build()
|
||||
if (iconResourceId != Resources.ID_NULL) {
|
||||
val uri = Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(packageName)
|
||||
.path(iconResourceId.toString())
|
||||
.build()
|
||||
|
||||
val options = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
.override(size, size)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(uri)
|
||||
.submit(size, size)
|
||||
val options = RequestOptions()
|
||||
.format(DecodeFormat.PREFER_RGB_565)
|
||||
.override(size, size)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(options)
|
||||
.load(uri)
|
||||
.submit(size, size)
|
||||
|
||||
try {
|
||||
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||
try {
|
||||
data = target.get()?.getBytes(canHaveAlpha = true, recycle = false)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
|
||||
}
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
Glide.with(context).clear(target)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
|
||||
return
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.calls
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
|
@ -52,16 +53,24 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
|||
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
|
||||
bitmap = centerSquareCrop(context, bitmap, 256)
|
||||
if (bitmap != null) {
|
||||
icon = IconCompat.createWithBitmap(bitmap)
|
||||
// adaptive, so the bitmap is used as background and covers the whole icon
|
||||
icon = IconCompat.createWithAdaptiveBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
if (icon == null) {
|
||||
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection)
|
||||
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
|
||||
// so that foreground is rendered at the intended scale
|
||||
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
icon = IconCompat.createWithResource(context, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_collection else R.drawable.ic_shortcut_collection)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", filters.toTypedArray())
|
||||
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
|
||||
// so we use a joined `String` as fallback
|
||||
.putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
|
||||
|
||||
// multiple shortcuts sharing the same ID cannot be created with different labels or icons
|
||||
// so we provide a unique ID for each one, and let the user manage duplicates (i.e. same filter set), if any
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Context
|
|||
import android.database.Cursor
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
@ -77,6 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
|
||||
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
|
||||
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
|
||||
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
|
||||
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -681,6 +683,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
|
||||
}
|
||||
|
||||
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val prop = call.argument<String>("prop")
|
||||
if (prop == null) {
|
||||
result.error("hasContentResolverProp-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
result.success(
|
||||
when (prop) {
|
||||
"owner_package_name" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
else -> {
|
||||
result.error("hasContentResolverProp-unknown", "unknown property=$prop", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
|
|
@ -81,7 +81,7 @@ class ThumbnailFetcher internal constructor(
|
|||
if (errorDetails?.isNotEmpty() == true) {
|
||||
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
|
||||
}
|
||||
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
|
||||
result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -95,22 +95,22 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
}
|
||||
|
||||
if (isVideo(mimeType)) {
|
||||
streamVideoByGlide(uri)
|
||||
streamVideoByGlide(uri, mimeType)
|
||||
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
|
||||
// decode exotic format on platform side, then encode it in portable format for Flutter
|
||||
streamImageByGlide(uri, pageId, mimeType, rotationDegrees, isFlipped)
|
||||
} else {
|
||||
// to be decoded by Flutter
|
||||
streamImageAsIs(uri)
|
||||
streamImageAsIs(uri, mimeType)
|
||||
}
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
private fun streamImageAsIs(uri: Uri) {
|
||||
private fun streamImageAsIs(uri: Uri, mimeType: String) {
|
||||
try {
|
||||
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
|
||||
} catch (e: IOException) {
|
||||
error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message)
|
||||
error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,16 +137,16 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
|
||||
} else {
|
||||
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
|
||||
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error("streamImage-image-decode-exception", "failed to get image from uri=$uri model=$model", toErrorDetails(e))
|
||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamVideoByGlide(uri: Uri) {
|
||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
|
@ -158,10 +158,10 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(canHaveAlpha = false, recycle = false))
|
||||
} else {
|
||||
error("streamImage-video-null", "failed to get image from uri=$uri", null)
|
||||
error("streamImage-video-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error("streamImage-video-exception", "failed to get image from uri=$uri", e.message)
|
||||
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import android.os.Handler
|
|||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.PendingResultHandler
|
||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -82,7 +82,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
activity.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
|
@ -116,7 +116,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
}
|
||||
MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
activity.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
|
@ -138,7 +138,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
|
||||
MainActivity.pendingResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
success(StorageUtils.convertTreeUriToDirPath(activity, uri))
|
||||
endOfStream()
|
||||
}, {
|
||||
|
|
|
@ -161,7 +161,7 @@ abstract class ImageProvider {
|
|||
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")
|
||||
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
|
||||
|
||||
destinationDocFile.openOutputStream().use { output ->
|
||||
if (exportMimeType == MimeTypes.BMP) {
|
||||
|
|
|
@ -37,6 +37,7 @@ object MimeTypes {
|
|||
|
||||
private const val VIDEO = "video"
|
||||
|
||||
private const val MKV = "video/x-matroska"
|
||||
private const val MP2T = "video/mp2t"
|
||||
private const val MP2TS = "video/mp2ts"
|
||||
const val MP4 = "video/mp4"
|
||||
|
@ -72,7 +73,7 @@ object MimeTypes {
|
|||
|
||||
// as of `metadata-extractor` v2.14.0
|
||||
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
DJVU, WBMP, MP2T, MP2TS, OGV, WEBM -> false
|
||||
DJVU, WBMP, MKV, MP2T, MP2TS, OGV, WEBM -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import android.os.storage.StorageManager
|
|||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.PendingResultHandler
|
||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
@ -35,7 +35,7 @@ object PermissionManager {
|
|||
}
|
||||
|
||||
if (intent.resolveActivity(activity.packageManager) != null) {
|
||||
MainActivity.pendingResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingResultHandler(path, onGranted, onDenied)
|
||||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
|
||||
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
|
||||
} else {
|
||||
Log.e(LOG_TAG, "failed to resolve activity for intent=$intent")
|
||||
|
|
|
@ -461,6 +461,8 @@ object StorageUtils {
|
|||
val effectiveUri = getOriginalUri(context, uri)
|
||||
return try {
|
||||
MediaMetadataRetriever().apply {
|
||||
// on Android S preview, setting the data source works but yields an internal IOException
|
||||
// (`Input file descriptor already original`), whether we provide the original URI or not
|
||||
setDataSource(context, effectiveUri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108"
|
||||
android:tint="#455A64">
|
||||
<group android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
|
||||
</group>
|
||||
</vector>
|
|
@ -1,15 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108"
|
||||
android:tint="#455A64">
|
||||
<group android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z"/>
|
||||
</group>
|
||||
</vector>
|
|
@ -1,15 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108"
|
||||
android:tint="#455A64">
|
||||
<group android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_background"
|
||||
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_foreground"
|
||||
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
|
||||
</group>
|
||||
</vector>
|
16
android/app/src/main/res/drawable-v21/ic_shortcut_movie.xml
Normal file
16
android/app/src/main/res/drawable-v21/ic_shortcut_movie.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_background"
|
||||
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_foreground"
|
||||
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z" />
|
||||
</group>
|
||||
</vector>
|
16
android/app/src/main/res/drawable-v21/ic_shortcut_search.xml
Normal file
16
android/app/src/main/res/drawable-v21/ic_shortcut_search.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_background"
|
||||
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/ic_shortcut_foreground"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="@color/ic_shortcut_foreground"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="@color/ic_shortcut_foreground"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z" />
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="@color/ic_shortcut_foreground"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:scaleX="1.7226"
|
||||
android:scaleY="1.7226"
|
||||
android:translateX="33.3288"
|
||||
android:translateY="33.3288">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||
</group>
|
||||
</vector>
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_shortcut_background"/>
|
||||
<foreground android:drawable="@drawable/ic_shortcut_collection_foreground"/>
|
||||
<background android:drawable="@color/ic_shortcut_background" />
|
||||
<foreground android:drawable="@drawable/ic_shortcut_collection_foreground" />
|
||||
</adaptive-icon>
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_shortcut_background"/>
|
||||
<foreground android:drawable="@drawable/ic_shortcut_movie_foreground"/>
|
||||
<background android:drawable="@color/ic_shortcut_background" />
|
||||
<foreground android:drawable="@drawable/ic_shortcut_movie_foreground" />
|
||||
</adaptive-icon>
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_shortcut_background"/>
|
||||
<foreground android:drawable="@drawable/ic_shortcut_search_foreground"/>
|
||||
<background android:drawable="@color/ic_shortcut_background" />
|
||||
<foreground android:drawable="@drawable/ic_shortcut_search_foreground" />
|
||||
</adaptive-icon>
|
|
@ -2,5 +2,6 @@
|
|||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
<color name="ic_shortcut_background">#FFFFFF</color>
|
||||
<color name="ic_shortcut_foreground">#455A64</color>
|
||||
<color name="ic_launcher_flavour">#3f51b5</color>
|
||||
</resources>
|
|
@ -8,7 +8,7 @@ buildscript {
|
|||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.0.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.8'
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/services/android_app_service.dart';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:transparent_image/transparent_image.dart';
|
||||
|
||||
class AppIconImage extends ImageProvider<AppIconImageKey> {
|
||||
const AppIconImage({
|
||||
|
@ -39,10 +40,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
|||
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
||||
try {
|
||||
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
|
||||
if (bytes.isEmpty) {
|
||||
throw StateError('$packageName app icon loading failed');
|
||||
}
|
||||
return await decode(bytes);
|
||||
return await decode(bytes.isEmpty ? kTransparentImage : bytes);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
|
||||
throw StateError('$packageName app icon decoding failed');
|
||||
|
|
|
@ -24,12 +24,20 @@
|
|||
"@hideButtonLabel": {},
|
||||
"continueButtonLabel": "CONTINUE",
|
||||
"@continueButtonLabel": {},
|
||||
"changeTooltip": "Change",
|
||||
"@changeTooltip": {},
|
||||
"clearTooltip": "Clear",
|
||||
"@clearTooltip": {},
|
||||
"previousTooltip": "Previous",
|
||||
"@previousTooltip": {},
|
||||
"nextTooltip": "Next",
|
||||
"@nextTooltip": {},
|
||||
"showTooltip": "Show",
|
||||
"@showTooltip": {},
|
||||
"hideTooltip": "Hide",
|
||||
"@hideTooltip": {},
|
||||
"removeTooltip": "Remove",
|
||||
"@removeTooltip": {},
|
||||
|
||||
"doubleBackExitMessage": "Tap “back” again to exit.",
|
||||
"@doubleBackExitMessage": {},
|
||||
|
@ -321,6 +329,12 @@
|
|||
"@menuActionSort": {},
|
||||
"menuActionGroup": "Group",
|
||||
"@menuActionGroup": {},
|
||||
"menuActionSelect": "Select",
|
||||
"@menuActionSelect": {},
|
||||
"menuActionSelectAll": "Select all",
|
||||
"@menuActionSelectAll": {},
|
||||
"menuActionSelectNone": "Select none",
|
||||
"@menuActionSelectNone": {},
|
||||
"menuActionMap": "Map",
|
||||
"@menuActionMap": {},
|
||||
"menuActionStats": "Stats",
|
||||
|
@ -376,12 +390,6 @@
|
|||
|
||||
"collectionActionAddShortcut": "Add shortcut",
|
||||
"@collectionActionAddShortcut": {},
|
||||
"collectionActionSelect": "Select",
|
||||
"@collectionActionSelect": {},
|
||||
"collectionActionSelectAll": "Select all",
|
||||
"@collectionActionSelectAll": {},
|
||||
"collectionActionSelectNone": "Select none",
|
||||
"@collectionActionSelectNone": {},
|
||||
"collectionActionCopy": "Copy to album",
|
||||
"@collectionActionCopy": {},
|
||||
"collectionActionMove": "Move to album",
|
||||
|
@ -476,10 +484,18 @@
|
|||
|
||||
"drawerCollectionAll": "All collection",
|
||||
"@drawerCollectionAll": {},
|
||||
"drawerCollectionVideos": "Videos",
|
||||
"@drawerCollectionVideos": {},
|
||||
"drawerCollectionFavourites": "Favourites",
|
||||
"@drawerCollectionFavourites": {},
|
||||
"drawerCollectionImages": "Images",
|
||||
"@drawerCollectionImages": {},
|
||||
"drawerCollectionVideos": "Videos",
|
||||
"@drawerCollectionVideos": {},
|
||||
"drawerCollectionMotionPhotos": "Motion photos",
|
||||
"@drawerCollectionMotionPhotos": {},
|
||||
"drawerCollectionPanoramas": "Panoramas",
|
||||
"@drawerCollectionPanoramas": {},
|
||||
"drawerCollectionSphericalVideos": "360° Videos",
|
||||
"@drawerCollectionSphericalVideos": {},
|
||||
|
||||
"chipSortTitle": "Sort",
|
||||
"@chipSortTitle": {},
|
||||
|
@ -505,6 +521,8 @@
|
|||
"@albumPickPageTitleExport": {},
|
||||
"albumPickPageTitleMove": "Move to Album",
|
||||
"@albumPickPageTitleMove": {},
|
||||
"albumPickPageTitlePick": "Pick Album",
|
||||
"@albumPickPageTitlePick": {},
|
||||
|
||||
"albumCamera": "Camera",
|
||||
"@albumCamera": {},
|
||||
|
@ -572,6 +590,21 @@
|
|||
"settingsDoubleBackExit": "Tap “back” twice to exit",
|
||||
"@settingsDoubleBackExit": {},
|
||||
|
||||
"settingsNavigationDrawerTile": "Navigation menu",
|
||||
"@settingsNavigationDrawerTile": {},
|
||||
"settingsNavigationDrawerEditorTitle": "Navigation Menu",
|
||||
"@settingsNavigationDrawerEditorTitle": {},
|
||||
"settingsNavigationDrawerBanner": "Touch and hold to move and reorder menu items.",
|
||||
"@settingsNavigationDrawerBanner": {},
|
||||
"settingsNavigationDrawerTabTypes": "Types",
|
||||
"@settingsNavigationDrawerTabTypes": {},
|
||||
"settingsNavigationDrawerTabAlbums": "Albums",
|
||||
"@settingsNavigationDrawerTabAlbums": {},
|
||||
"settingsNavigationDrawerTabPages": "Pages",
|
||||
"@settingsNavigationDrawerTabPages": {},
|
||||
"settingsNavigationDrawerAddAlbum": "Add album",
|
||||
"@settingsNavigationDrawerAddAlbum": {},
|
||||
|
||||
"settingsSectionThumbnails": "Thumbnails",
|
||||
"@settingsSectionThumbnails": {},
|
||||
"settingsThumbnailShowLocationIcon": "Show location icon",
|
||||
|
@ -683,8 +716,6 @@
|
|||
"@settingsHiddenPathsBanner": {},
|
||||
"settingsHiddenPathsEmpty": "No hidden paths",
|
||||
"@settingsHiddenPathsEmpty": {},
|
||||
"settingsHiddenPathsRemoveTooltip": "Remove",
|
||||
"@settingsHiddenPathsRemoveTooltip": {},
|
||||
"addPathTooltip": "Add path",
|
||||
"@addPathTooltip": {},
|
||||
|
||||
|
|
|
@ -10,9 +10,13 @@
|
|||
"showButtonLabel": "보기",
|
||||
"hideButtonLabel": "숨기기",
|
||||
"continueButtonLabel": "다음",
|
||||
"changeTooltip": "변경",
|
||||
"clearTooltip": "초기화",
|
||||
"previousTooltip": "이전",
|
||||
"nextTooltip": "다음",
|
||||
"showTooltip": "보기",
|
||||
"hideTooltip": "숨기기",
|
||||
"removeTooltip": "제거",
|
||||
|
||||
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
|
||||
|
||||
|
@ -147,6 +151,9 @@
|
|||
|
||||
"menuActionSort": "정렬",
|
||||
"menuActionGroup": "묶음",
|
||||
"menuActionSelect": "선택",
|
||||
"menuActionSelectAll": "모두 선택",
|
||||
"menuActionSelectNone": "모두 해제",
|
||||
"menuActionMap": "지도",
|
||||
"menuActionStats": "통계",
|
||||
|
||||
|
@ -174,9 +181,6 @@
|
|||
"collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}",
|
||||
|
||||
"collectionActionAddShortcut": "홈 화면에 추가",
|
||||
"collectionActionSelect": "선택",
|
||||
"collectionActionSelectAll": "모두 선택",
|
||||
"collectionActionSelectNone": "모두 해제",
|
||||
"collectionActionCopy": "앨범으로 복사",
|
||||
"collectionActionMove": "앨범으로 이동",
|
||||
"collectionActionRefreshMetadata": "새로 분석",
|
||||
|
@ -212,8 +216,12 @@
|
|||
"collectionDeselectSectionTooltip": "묶음 선택 해제",
|
||||
|
||||
"drawerCollectionAll": "모든 미디어",
|
||||
"drawerCollectionVideos": "동영상",
|
||||
"drawerCollectionFavourites": "즐겨찾기",
|
||||
"drawerCollectionImages": "사진",
|
||||
"drawerCollectionVideos": "동영상",
|
||||
"drawerCollectionMotionPhotos": "모션 포토",
|
||||
"drawerCollectionPanoramas": "파노라마",
|
||||
"drawerCollectionSphericalVideos": "360° 동영상",
|
||||
|
||||
"chipSortTitle": "정렬",
|
||||
"chipSortDate": "날짜",
|
||||
|
@ -228,6 +236,7 @@
|
|||
"albumPickPageTitleCopy": "앨범으로 복사",
|
||||
"albumPickPageTitleExport": "앨범으로 내보내기",
|
||||
"albumPickPageTitleMove": "앨범으로 이동",
|
||||
"albumPickPageTitlePick": "앨범 선택",
|
||||
|
||||
"albumCamera": "카메라",
|
||||
"albumDownload": "다운로드",
|
||||
|
@ -266,6 +275,14 @@
|
|||
"settingsKeepScreenOnTitle": "화면 자동 꺼짐 방지",
|
||||
"settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기",
|
||||
|
||||
"settingsNavigationDrawerTile": "탐색 메뉴",
|
||||
"settingsNavigationDrawerEditorTitle": "탐색 메뉴",
|
||||
"settingsNavigationDrawerBanner": "항목을 길게 누른 후 이동하여 탐색 메뉴에 표시될 항목의 순서를 수정하세요.",
|
||||
"settingsNavigationDrawerTabTypes": "유형",
|
||||
"settingsNavigationDrawerTabAlbums": "앨범",
|
||||
"settingsNavigationDrawerTabPages": "페이지",
|
||||
"settingsNavigationDrawerAddAlbum": "앨범 추가",
|
||||
|
||||
"settingsSectionThumbnails": "섬네일",
|
||||
"settingsThumbnailShowLocationIcon": "위치 아이콘 표시",
|
||||
"settingsThumbnailShowRawIcon": "Raw 아이콘 표시",
|
||||
|
@ -325,7 +342,6 @@
|
|||
"settingsHiddenPathsTitle": "숨겨진 경로",
|
||||
"settingsHiddenPathsBanner": "이 경로에 있는 사진과 동영상이 숨겨지고 있으며 이 앱에서 보여지지 않을 것입니다.",
|
||||
"settingsHiddenPathsEmpty": "숨겨진 경로가 없습니다",
|
||||
"settingsHiddenPathsRemoveTooltip": "제거",
|
||||
"addPathTooltip": "경로 추가",
|
||||
|
||||
"settingsStorageAccessTile": "저장공간 접근",
|
||||
|
|
|
@ -23,7 +23,11 @@ extension ExtraChipAction on ChipAction {
|
|||
}
|
||||
}
|
||||
|
||||
IconData getIcon() {
|
||||
Widget getIcon() {
|
||||
return Icon(_getIconData());
|
||||
}
|
||||
|
||||
IconData _getIconData() {
|
||||
switch (this) {
|
||||
case ChipAction.goToAlbumPage:
|
||||
return AIcons.album;
|
||||
|
|
|
@ -6,18 +6,19 @@ enum ChipSetAction {
|
|||
// general
|
||||
sort,
|
||||
group,
|
||||
map,
|
||||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
stats,
|
||||
createAlbum,
|
||||
// single/multiple filters
|
||||
// all or filter selection
|
||||
map,
|
||||
stats,
|
||||
// single/multiple filter selection
|
||||
delete,
|
||||
hide,
|
||||
pin,
|
||||
unpin,
|
||||
// single filter
|
||||
// single filter selection
|
||||
rename,
|
||||
setCover,
|
||||
}
|
||||
|
@ -31,11 +32,11 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
case ChipSetAction.group:
|
||||
return context.l10n.menuActionGroup;
|
||||
case ChipSetAction.select:
|
||||
return context.l10n.collectionActionSelect;
|
||||
return context.l10n.menuActionSelect;
|
||||
case ChipSetAction.selectAll:
|
||||
return context.l10n.collectionActionSelectAll;
|
||||
return context.l10n.menuActionSelectAll;
|
||||
case ChipSetAction.selectNone:
|
||||
return context.l10n.collectionActionSelectNone;
|
||||
return context.l10n.menuActionSelectNone;
|
||||
case ChipSetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
case ChipSetAction.stats:
|
||||
|
@ -59,7 +60,11 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
}
|
||||
}
|
||||
|
||||
IconData? getIcon() {
|
||||
Widget getIcon() {
|
||||
return Icon(_getIconData());
|
||||
}
|
||||
|
||||
IconData _getIconData() {
|
||||
switch (this) {
|
||||
// general
|
||||
case ChipSetAction.sort:
|
||||
|
@ -69,14 +74,15 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
case ChipSetAction.select:
|
||||
return AIcons.select;
|
||||
case ChipSetAction.selectAll:
|
||||
return AIcons.selected;
|
||||
case ChipSetAction.selectNone:
|
||||
return null;
|
||||
return AIcons.unselected;
|
||||
case ChipSetAction.map:
|
||||
return AIcons.map;
|
||||
case ChipSetAction.stats:
|
||||
return AIcons.stats;
|
||||
case ChipSetAction.createAlbum:
|
||||
return AIcons.createAlbum;
|
||||
return AIcons.add;
|
||||
// single/multiple filters
|
||||
case ChipSetAction.delete:
|
||||
return AIcons.delete;
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
enum CollectionAction {
|
||||
addShortcut,
|
||||
sort,
|
||||
group,
|
||||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
map,
|
||||
stats,
|
||||
// apply to entry set
|
||||
copy,
|
||||
move,
|
||||
refreshMetadata,
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -115,7 +116,23 @@ extension ExtraEntryAction on EntryAction {
|
|||
}
|
||||
}
|
||||
|
||||
IconData? getIcon() {
|
||||
Widget? getIcon() {
|
||||
final icon = getIconData();
|
||||
if (icon == null) return null;
|
||||
|
||||
final child = Icon(icon);
|
||||
switch (this) {
|
||||
case EntryAction.debug:
|
||||
return ShaderMask(
|
||||
shaderCallback: Themes.debugGradient.createShader,
|
||||
child: child,
|
||||
);
|
||||
default:
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
IconData? getIconData() {
|
||||
switch (this) {
|
||||
case EntryAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
|
|
89
lib/model/actions/entry_set_actions.dart
Normal file
89
lib/model/actions/entry_set_actions.dart
Normal file
|
@ -0,0 +1,89 @@
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum EntrySetAction {
|
||||
// general
|
||||
sort,
|
||||
group,
|
||||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
// all
|
||||
addShortcut,
|
||||
// all or entry selection
|
||||
map,
|
||||
stats,
|
||||
// entry selection
|
||||
copy,
|
||||
move,
|
||||
refreshMetadata,
|
||||
}
|
||||
|
||||
extension ExtraEntrySetAction on EntrySetAction {
|
||||
String getText(BuildContext context) {
|
||||
switch (this) {
|
||||
// general
|
||||
case EntrySetAction.sort:
|
||||
return context.l10n.menuActionSort;
|
||||
case EntrySetAction.group:
|
||||
return context.l10n.menuActionGroup;
|
||||
case EntrySetAction.select:
|
||||
return context.l10n.menuActionSelect;
|
||||
case EntrySetAction.selectAll:
|
||||
return context.l10n.menuActionSelectAll;
|
||||
case EntrySetAction.selectNone:
|
||||
return context.l10n.menuActionSelectNone;
|
||||
// all
|
||||
case EntrySetAction.addShortcut:
|
||||
return context.l10n.collectionActionAddShortcut;
|
||||
// all or entry selection
|
||||
case EntrySetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
case EntrySetAction.stats:
|
||||
return context.l10n.menuActionStats;
|
||||
// entry selection
|
||||
case EntrySetAction.copy:
|
||||
return context.l10n.collectionActionCopy;
|
||||
case EntrySetAction.move:
|
||||
return context.l10n.collectionActionMove;
|
||||
case EntrySetAction.refreshMetadata:
|
||||
return context.l10n.collectionActionRefreshMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
Widget getIcon() {
|
||||
return Icon(_getIconData());
|
||||
}
|
||||
|
||||
IconData _getIconData() {
|
||||
switch (this) {
|
||||
// general
|
||||
case EntrySetAction.sort:
|
||||
return AIcons.sort;
|
||||
case EntrySetAction.group:
|
||||
return AIcons.group;
|
||||
case EntrySetAction.select:
|
||||
return AIcons.select;
|
||||
case EntrySetAction.selectAll:
|
||||
return AIcons.selected;
|
||||
case EntrySetAction.selectNone:
|
||||
return AIcons.unselected;
|
||||
// all
|
||||
case EntrySetAction.addShortcut:
|
||||
return AIcons.addShortcut;
|
||||
// all or entry selection
|
||||
case EntrySetAction.map:
|
||||
return AIcons.map;
|
||||
case EntrySetAction.stats:
|
||||
return AIcons.stats;
|
||||
// entry selection
|
||||
case EntrySetAction.copy:
|
||||
return AIcons.copy;
|
||||
case EntrySetAction.move:
|
||||
return AIcons.move;
|
||||
case EntrySetAction.refreshMetadata:
|
||||
return AIcons.refresh;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,7 +46,11 @@ extension ExtraVideoAction on VideoAction {
|
|||
}
|
||||
}
|
||||
|
||||
IconData? getIcon() {
|
||||
Widget getIcon() {
|
||||
return Icon(_getIconData());
|
||||
}
|
||||
|
||||
IconData _getIconData() {
|
||||
switch (this) {
|
||||
case VideoAction.captureFrame:
|
||||
return AIcons.captureFrame;
|
||||
|
|
|
@ -43,15 +43,6 @@ class AvesEntry {
|
|||
|
||||
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
|
||||
|
||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
static const List<String> undecodable = [
|
||||
MimeTypes.art,
|
||||
MimeTypes.crw,
|
||||
MimeTypes.djvu,
|
||||
MimeTypes.psdVnd,
|
||||
MimeTypes.psdX,
|
||||
];
|
||||
|
||||
AvesEntry({
|
||||
required this.uri,
|
||||
required String? path,
|
||||
|
@ -74,7 +65,7 @@ class AvesEntry {
|
|||
this.durationMillis = durationMillis;
|
||||
}
|
||||
|
||||
bool get canDecode => !undecodable.contains(mimeType);
|
||||
bool get canDecode => !MimeTypes.undecodableImages.contains(mimeType);
|
||||
|
||||
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
|
||||
|
||||
|
@ -238,9 +229,10 @@ class AvesEntry {
|
|||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// support for writing EXIF
|
||||
// as of androidx.exifinterface:exifinterface:1.3.0
|
||||
// as of androidx.exifinterface:exifinterface:1.3.3
|
||||
bool get canEditExif {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.dng:
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.png:
|
||||
case MimeTypes.webp:
|
||||
|
|
|
@ -5,41 +5,45 @@ class Selection<T> extends ChangeNotifier {
|
|||
|
||||
bool get isSelecting => _isSelecting;
|
||||
|
||||
final Set<T> _selection = {};
|
||||
final Set<T> _selectedItems = {};
|
||||
|
||||
Set<T> get selection => _selection;
|
||||
Set<T> get selectedItems => _selectedItems;
|
||||
|
||||
void browse() {
|
||||
if (!_isSelecting) return;
|
||||
clearSelection();
|
||||
_isSelecting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void select() {
|
||||
if (_isSelecting) return;
|
||||
_isSelecting = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isSelected(Iterable<T> items) => items.every(selection.contains);
|
||||
bool isSelected(Iterable<T> items) => items.every(selectedItems.contains);
|
||||
|
||||
void addToSelection(Iterable<T> items) {
|
||||
_selection.addAll(items);
|
||||
if (items.isEmpty) return;
|
||||
_selectedItems.addAll(items);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeFromSelection(Iterable<T> items) {
|
||||
_selection.removeAll(items);
|
||||
if (items.isEmpty) return;
|
||||
_selectedItems.removeAll(items);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
_selection.clear();
|
||||
_selectedItems.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleSelection(T item) {
|
||||
if (_selection.isEmpty) select();
|
||||
if (!_selection.remove(item)) _selection.add(item);
|
||||
if (_selectedItems.isEmpty) select();
|
||||
if (!_selectedItems.remove(item)) _selectedItems.add(item);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,9 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/video_actions.dart';
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/screen_on.dart';
|
||||
|
@ -11,11 +13,13 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/services/device_service.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final Settings settings = Settings._private();
|
||||
|
@ -47,6 +51,11 @@ class Settings extends ChangeNotifier {
|
|||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const tileExtentPrefixKey = 'tile_extent_';
|
||||
|
||||
// drawer
|
||||
static const drawerTypeBookmarksKey = 'drawer_type_bookmarks';
|
||||
static const drawerAlbumBookmarksKey = 'drawer_album_bookmarks';
|
||||
static const drawerPageBookmarksKey = 'drawer_page_bookmarks';
|
||||
|
||||
// collection
|
||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
|
@ -100,6 +109,16 @@ class Settings extends ChangeNotifier {
|
|||
static const lastVersionCheckDateKey = 'last_version_check_date';
|
||||
|
||||
// defaults
|
||||
static final drawerTypeBookmarksDefault = [
|
||||
null,
|
||||
MimeFilter.video,
|
||||
FavouriteFilter.instance,
|
||||
];
|
||||
static final drawerPageBookmarksDefault = [
|
||||
AlbumListPage.routeName,
|
||||
CountryListPage.routeName,
|
||||
TagListPage.routeName,
|
||||
];
|
||||
static const viewerQuickActionsDefault = [
|
||||
EntryAction.toggleFavourite,
|
||||
EntryAction.share,
|
||||
|
@ -209,6 +228,25 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
|
||||
|
||||
// drawer
|
||||
|
||||
List<CollectionFilter?> get drawerTypeBookmarks =>
|
||||
(_prefs!.getStringList(drawerTypeBookmarksKey))?.map((v) {
|
||||
if (v.isEmpty) return null;
|
||||
return CollectionFilter.fromJson(v);
|
||||
}).toList() ??
|
||||
drawerTypeBookmarksDefault;
|
||||
|
||||
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
|
||||
|
||||
List<String>? get drawerAlbumBookmarks => _prefs!.getStringList(drawerAlbumBookmarksKey);
|
||||
|
||||
set drawerAlbumBookmarks(List<String>? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue);
|
||||
|
||||
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? drawerPageBookmarksDefault;
|
||||
|
||||
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
|
||||
|
||||
// collection
|
||||
|
||||
EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
|
||||
|
@ -447,7 +485,9 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// apply user modifications
|
||||
jsonMap.forEach((key, value) {
|
||||
if (key.startsWith(tileExtentPrefixKey)) {
|
||||
if (value == null) {
|
||||
_prefs!.remove(key);
|
||||
} else if (key.startsWith(tileExtentPrefixKey)) {
|
||||
if (value is double) {
|
||||
_prefs!.setDouble(key, value);
|
||||
} else {
|
||||
|
@ -511,6 +551,9 @@ class Settings extends ChangeNotifier {
|
|||
debugPrint('failed to import key=$key, value=$value is not a string');
|
||||
}
|
||||
break;
|
||||
case drawerTypeBookmarksKey:
|
||||
case drawerAlbumBookmarksKey:
|
||||
case drawerPageBookmarksKey:
|
||||
case pinnedFiltersKey:
|
||||
case hiddenFiltersKey:
|
||||
case viewerQuickActionsKey:
|
||||
|
|
|
@ -120,8 +120,13 @@ mixin AlbumMixin on SourceBase {
|
|||
_notifyAlbumChange();
|
||||
invalidateAlbumFilterSummary(directories: emptyAlbums);
|
||||
|
||||
final bookmarks = settings.drawerAlbumBookmarks;
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
emptyAlbums.forEach((album) => pinnedFilters.removeWhere((filter) => filter is AlbumFilter && filter.album == album));
|
||||
emptyAlbums.forEach((album) {
|
||||
bookmarks?.remove(album);
|
||||
pinnedFilters.removeWhere((filter) => filter is AlbumFilter && filter.album == album);
|
||||
});
|
||||
settings.drawerAlbumBookmarks = bookmarks;
|
||||
settings.pinnedFilters = pinnedFilters;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -159,6 +159,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
|
||||
final oldFilter = AlbumFilter(sourceAlbum, null);
|
||||
final bookmarked = settings.drawerAlbumBookmarks?.contains(sourceAlbum) == true;
|
||||
final pinned = settings.pinnedFilters.contains(oldFilter);
|
||||
final oldCoverContentId = covers.coverContentId(oldFilter);
|
||||
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
|
||||
|
@ -169,8 +170,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
destinationAlbum: destinationAlbum,
|
||||
movedOps: movedOps,
|
||||
);
|
||||
// restore pin and cover, as the obsolete album got removed and its associated state cleaned
|
||||
// restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned
|
||||
final newFilter = AlbumFilter(destinationAlbum, null);
|
||||
if (bookmarked) {
|
||||
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum);
|
||||
}
|
||||
if (pinned) {
|
||||
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
|
||||
}
|
||||
|
|
|
@ -41,18 +41,29 @@ class MimeTypes {
|
|||
static const anyVideo = 'video/*';
|
||||
|
||||
static const avi = 'video/avi';
|
||||
static const mkv = 'video/x-matroska';
|
||||
static const mov = 'video/quicktime';
|
||||
static const mp2t = 'video/mp2t'; // .m2ts
|
||||
static const mp4 = 'video/mp4';
|
||||
static const ogg = 'video/ogg';
|
||||
|
||||
static const json = 'application/json';
|
||||
|
||||
// groups
|
||||
|
||||
// formats that support transparency
|
||||
static const List<String> alphaImages = [bmp, gif, ico, png, svg, tiff, webp];
|
||||
static const Set<String> alphaImages = {bmp, gif, ico, png, svg, tiff, webp};
|
||||
|
||||
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
|
||||
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
|
||||
|
||||
// TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
|
||||
|
||||
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
|
||||
|
||||
static const Set<String> _knownVideos = {avi, mkv, mov, mp2t, mp4, ogg};
|
||||
|
||||
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
|
||||
|
||||
static bool isImage(String mimeType) => mimeType.startsWith('image');
|
||||
|
||||
|
|
|
@ -21,8 +21,8 @@ class AndroidAppService {
|
|||
kakaoTalk.ownedDirs.add('KakaoTalkDownload');
|
||||
}
|
||||
return packages;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getPackages', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -34,8 +34,8 @@ class AndroidAppService {
|
|||
'sizeDip': size,
|
||||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getAppIcon', e);
|
||||
} on PlatformException catch (_, __) {
|
||||
// ignore, as some packages legitimately do not have icons
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
@ -47,8 +47,8 @@ class AndroidAppService {
|
|||
'label': label,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('copyToClipboard', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -60,8 +60,8 @@ class AndroidAppService {
|
|||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('edit', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -73,8 +73,8 @@ class AndroidAppService {
|
|||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('open', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -89,8 +89,8 @@ class AndroidAppService {
|
|||
'geoUri': geoUri,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('openMap', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -102,8 +102,8 @@ class AndroidAppService {
|
|||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('setAs', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -117,8 +117,8 @@ class AndroidAppService {
|
|||
'urisByMimeType': urisByMimeType,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('shareEntries', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -131,8 +131,8 @@ class AndroidAppService {
|
|||
},
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('shareSingle', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -9,40 +9,40 @@ class AndroidDebugService {
|
|||
static Future<void> crash() async {
|
||||
try {
|
||||
await platform.invokeMethod('crash');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('crash', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> exception() async {
|
||||
try {
|
||||
await platform.invokeMethod('exception');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('exception', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> safeException() async {
|
||||
try {
|
||||
await platform.invokeMethod('safeException');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('safeException', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> exceptionInCoroutine() async {
|
||||
try {
|
||||
await platform.invokeMethod('exceptionInCoroutine');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('exceptionInCoroutine', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> safeExceptionInCoroutine() async {
|
||||
try {
|
||||
await platform.invokeMethod('safeExceptionInCoroutine');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('safeExceptionInCoroutine', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,8 +50,8 @@ class AndroidDebugService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('getContextDirs');
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getContextDirs', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -60,8 +60,8 @@ class AndroidDebugService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('getEnv');
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getEnv', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -73,8 +73,8 @@ class AndroidDebugService {
|
|||
'uri': entry.uri,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getBitmapFactoryInfo', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -87,8 +87,8 @@ class AndroidDebugService {
|
|||
'uri': entry.uri,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getContentResolverMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -102,8 +102,8 @@ class AndroidDebugService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getExifInterfaceMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -115,8 +115,8 @@ class AndroidDebugService {
|
|||
'uri': entry.uri,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -130,8 +130,8 @@ class AndroidDebugService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getMetadataExtractorSummary', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -144,8 +144,8 @@ class AndroidDebugService {
|
|||
'uri': entry.uri,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getTiffStructure', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -13,9 +13,7 @@ class AppShortcutService {
|
|||
static bool? _canPin;
|
||||
|
||||
static Future<bool> canPin() async {
|
||||
if (_canPin != null) {
|
||||
return SynchronousFuture(_canPin!);
|
||||
}
|
||||
if (_canPin != null) return SynchronousFuture(_canPin!);
|
||||
|
||||
try {
|
||||
final result = await platform.invokeMethod('canPin');
|
||||
|
@ -23,8 +21,8 @@ class AppShortcutService {
|
|||
_canPin = result;
|
||||
return result;
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('canPin', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -49,8 +47,8 @@ class AppShortcutService {
|
|||
'iconBytes': iconBytes,
|
||||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('pin', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ class DeviceService {
|
|||
await platform.invokeMethod('getPerformanceClass');
|
||||
final result = await platform.invokeMethod('getPerformanceClass');
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getPerformanceClass', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -26,8 +26,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
if (result != null) return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getExifThumbnail', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -42,8 +42,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'displayName': '${entry.bestTitle} • Video',
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('extractMotionPhotoVideo', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -56,8 +56,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'displayName': '${entry.bestTitle} • Cover',
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('extractVideoEmbeddedPicture', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -74,8 +74,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
'propMimeType': propMimeType,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('extractXmpDataProp', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -20,8 +20,8 @@ class GeocodingService {
|
|||
'maxResults': 2,
|
||||
});
|
||||
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getAddress', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
|
@ -14,8 +14,8 @@ class GlobalSearch {
|
|||
await platform.invokeMethod('registerCallback', <String, dynamic>{
|
||||
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('registerCallback', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/output_buffer.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
|
@ -124,8 +125,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'mimeType': mimeType,
|
||||
}) as Map;
|
||||
return AvesEntry.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getEntry', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -188,8 +189,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
reportService.recordChannelError('getImage', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
}
|
||||
return Future.sync(() => Uint8List(0));
|
||||
}
|
||||
|
@ -223,8 +224,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'imageHeight': imageSize.height.toInt(),
|
||||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getRegion', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return Uint8List(0);
|
||||
},
|
||||
|
@ -260,8 +261,10 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'defaultSizeDip': thumbnailDefaultSize,
|
||||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getThumbnail', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
if (!MimeTypes.knownMediaTypes.contains(mimeType)) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return Uint8List(0);
|
||||
},
|
||||
|
@ -274,8 +277,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
Future<void> clearSizedThumbnailDiskCache() async {
|
||||
try {
|
||||
return platform.invokeMethod('clearSizedThumbnailDiskCache');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('clearSizedThumbnailDiskCache', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -295,8 +298,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'op': 'delete',
|
||||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
}).map((event) => ImageOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
reportService.recordChannelError('delete', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -314,8 +317,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'copy': copy,
|
||||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
reportService.recordChannelError('move', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -333,8 +336,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'mimeType': mimeType,
|
||||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => ExportOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
reportService.recordChannelError('export', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -356,8 +359,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'destinationPath': destinationAlbum,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('captureFrame', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -371,8 +374,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'newName': newName,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('rename', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -386,8 +389,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'clockwise': clockwise,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('rotate', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -400,8 +403,8 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'entry': _toPlatformEntryMap(entry),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('flip', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
'knownContentIds': knownContentIds,
|
||||
});
|
||||
return (result as List).cast<int>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('checkObsoleteContentIds', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -38,8 +38,8 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
'knownPathById': knownPathById,
|
||||
});
|
||||
return (result as List).cast<int>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('checkObsoletePaths', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -50,8 +50,8 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'knownEntries': knownEntries,
|
||||
}).map((event) => AvesEntry.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
reportService.recordChannelError('getEntries', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/multipage.dart';
|
|||
import 'package:aves/model/panorama.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class MetadataService {
|
||||
|
@ -18,6 +19,8 @@ abstract class MetadataService {
|
|||
|
||||
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
|
||||
|
||||
Future<bool> hasContentResolverProp(String prop);
|
||||
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
|
||||
}
|
||||
|
||||
|
@ -35,8 +38,8 @@ class PlatformMetadataService implements MetadataService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getAllMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -65,8 +68,8 @@ class PlatformMetadataService implements MetadataService {
|
|||
}) as Map;
|
||||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getCatalogMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -91,8 +94,8 @@ class PlatformMetadataService implements MetadataService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return OverlayMetadata.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getOverlayMetadata', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -113,8 +116,8 @@ class PlatformMetadataService implements MetadataService {
|
|||
imagePage['rotationDegrees'] = entry.rotationDegrees;
|
||||
}
|
||||
return MultiPageInfo.fromPageMaps(entry, pageMaps);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getMultiPageInfo', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -131,12 +134,31 @@ class PlatformMetadataService implements MetadataService {
|
|||
'sizeBytes': entry.sizeBytes,
|
||||
}) as Map;
|
||||
return PanoramaInfo.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('PanoramaInfo', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, bool> _contentResolverProps = {};
|
||||
|
||||
@override
|
||||
Future<bool> hasContentResolverProp(String prop) async {
|
||||
var exists = _contentResolverProps[prop];
|
||||
if (exists != null) return SynchronousFuture(exists);
|
||||
|
||||
try {
|
||||
exists = await platform.invokeMethod('hasContentResolverProp', <String, dynamic>{
|
||||
'prop': prop,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
exists ??= false;
|
||||
_contentResolverProps[prop] = exists;
|
||||
return exists;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getContentResolverProp(AvesEntry entry, String prop) async {
|
||||
try {
|
||||
|
@ -145,8 +167,8 @@ class PlatformMetadataService implements MetadataService {
|
|||
'uri': entry.uri,
|
||||
'prop': prop,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getContentResolverProp', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class ReportService {
|
||||
bool get isCollectionEnabled;
|
||||
|
@ -16,10 +15,6 @@ abstract class ReportService {
|
|||
Future<void> recordError(dynamic exception, StackTrace? stack);
|
||||
|
||||
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails);
|
||||
|
||||
Future<void> recordChannelError(String method, PlatformException e) {
|
||||
return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null);
|
||||
}
|
||||
}
|
||||
|
||||
class CrashlyticsReportService extends ReportService {
|
||||
|
|
|
@ -47,8 +47,8 @@ class PlatformStorageService implements StorageService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('getStorageVolumes');
|
||||
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getStorageVolumes', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -60,8 +60,8 @@ class PlatformStorageService implements StorageService {
|
|||
'path': volume.path,
|
||||
});
|
||||
return result as int?;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getFreeSpace', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -71,8 +71,8 @@ class PlatformStorageService implements StorageService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('getGrantedDirectories');
|
||||
return (result as List).cast<String>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getGrantedDirectories', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -83,8 +83,8 @@ class PlatformStorageService implements StorageService {
|
|||
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
||||
'path': path,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('revokeDirectoryAccess', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -98,8 +98,8 @@ class PlatformStorageService implements StorageService {
|
|||
if (result != null) {
|
||||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getInaccessibleDirectories', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -111,8 +111,8 @@ class PlatformStorageService implements StorageService {
|
|||
if (result != null) {
|
||||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getRestrictedDirectories', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -134,8 +134,8 @@ class PlatformStorageService implements StorageService {
|
|||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('requestVolumeAccess', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -148,8 +148,8 @@ class PlatformStorageService implements StorageService {
|
|||
'dirPaths': dirPaths.toList(),
|
||||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('deleteEmptyDirectories', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
@ -164,8 +164,8 @@ class PlatformStorageService implements StorageService {
|
|||
'mimeType': mimeType,
|
||||
});
|
||||
if (result != null) return Uri.tryParse(result);
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('scanFile', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -188,8 +188,8 @@ class PlatformStorageService implements StorageService {
|
|||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('createFile', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -215,8 +215,8 @@ class PlatformStorageService implements StorageService {
|
|||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('openFile', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
@ -236,8 +236,8 @@ class PlatformStorageService implements StorageService {
|
|||
cancelOnError: true,
|
||||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('selectDirectory', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@ class PlatformTimeService implements TimeService {
|
|||
Future<String?> getDefaultTimeZone() async {
|
||||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getDefaultTimeZone', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ class ViewerService {
|
|||
// returns nullable map with 'action' and possibly 'uri' 'mimeType'
|
||||
final result = await platform.invokeMethod('getIntentData');
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('getIntentData', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -20,8 +20,8 @@ class ViewerService {
|
|||
await platform.invokeMethod('pick', <String, dynamic>{
|
||||
'uri': uri,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('pick', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,8 +23,8 @@ class PlatformWindowService implements WindowService {
|
|||
await platform.invokeMethod('keepScreenOn', <String, dynamic>{
|
||||
'on': on,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('keepScreenOn', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,8 +33,8 @@ class PlatformWindowService implements WindowService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('isRotationLocked');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('isRotationLocked', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -61,8 +61,8 @@ class PlatformWindowService implements WindowService {
|
|||
await platform.invokeMethod('requestOrientation', <String, dynamic>{
|
||||
'orientation': orientationCode,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('requestOrientation', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,8 +71,8 @@ class PlatformWindowService implements WindowService {
|
|||
try {
|
||||
final result = await platform.invokeMethod('canSetCutoutMode');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('canSetCutoutMode', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -83,8 +83,8 @@ class PlatformWindowService implements WindowService {
|
|||
await platform.invokeMethod('setCutoutMode', <String, dynamic>{
|
||||
'use': use,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
await reportService.recordChannelError('setCutoutMode', e);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,8 +43,8 @@ class Durations {
|
|||
static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500);
|
||||
static const viewerOverlayAnimation = Duration(milliseconds: 200);
|
||||
static const viewerOverlayChangeAnimation = Duration(milliseconds: 150);
|
||||
static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200);
|
||||
static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150);
|
||||
static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200);
|
||||
static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150);
|
||||
static const viewerVideoPlayerTransition = Duration(milliseconds: 500);
|
||||
|
||||
// info animations
|
||||
|
@ -69,6 +69,8 @@ class Durations {
|
|||
static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
|
||||
static const searchDebounceDelay = Duration(milliseconds: 250);
|
||||
static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
|
||||
static const mapScrollDebounceDelay = Duration(milliseconds: 150);
|
||||
static const mapIdleDebounceDelay = Duration(milliseconds: 100);
|
||||
|
||||
// app life
|
||||
static const lastVersionCheckInterval = Duration(days: 7);
|
||||
|
|
|
@ -30,14 +30,14 @@ class AIcons {
|
|||
static const IconData tagOff = MdiIcons.tagOffOutline;
|
||||
|
||||
// actions
|
||||
static const IconData addPath = Icons.add_circle_outline;
|
||||
static const IconData add = Icons.add_circle_outline;
|
||||
static const IconData addShortcut = Icons.add_to_home_screen_outlined;
|
||||
static const IconData replay10 = Icons.replay_10_outlined;
|
||||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||
static const IconData clear = Icons.clear_outlined;
|
||||
static const IconData clipboard = Icons.content_copy_outlined;
|
||||
static const IconData createAlbum = Icons.add_circle_outline;
|
||||
static const IconData copy = Icons.file_copy_outlined;
|
||||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
static const IconData export = MdiIcons.fileExportOutline;
|
||||
|
@ -51,6 +51,7 @@ class AIcons {
|
|||
static const IconData info = Icons.info_outlined;
|
||||
static const IconData layers = Icons.layers_outlined;
|
||||
static const IconData map = Icons.map_outlined;
|
||||
static const IconData move = MdiIcons.fileMoveOutline;
|
||||
static const IconData newTier = Icons.fiber_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
|
@ -58,6 +59,7 @@ class AIcons {
|
|||
static const IconData play = Icons.play_arrow;
|
||||
static const IconData pause = Icons.pause;
|
||||
static const IconData print = Icons.print_outlined;
|
||||
static const IconData refresh = Icons.refresh_outlined;
|
||||
static const IconData rename = Icons.title_outlined;
|
||||
static const IconData rotateLeft = Icons.rotate_left_outlined;
|
||||
static const IconData rotateRight = Icons.rotate_right_outlined;
|
||||
|
@ -67,6 +69,7 @@ class AIcons {
|
|||
static const IconData select = Icons.select_all_outlined;
|
||||
static const IconData setCover = MdiIcons.imageEditOutline;
|
||||
static const IconData share = Icons.share_outlined;
|
||||
static const IconData show = Icons.visibility_outlined;
|
||||
static const IconData sort = Icons.sort_outlined;
|
||||
static const IconData speed = Icons.speed_outlined;
|
||||
static const IconData stats = Icons.pie_chart_outlined;
|
||||
|
|
|
@ -6,6 +6,15 @@ import 'package:flutter/services.dart';
|
|||
class Themes {
|
||||
static const _accentColor = Colors.indigoAccent;
|
||||
|
||||
static const debugGradient = LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.red,
|
||||
Colors.amber,
|
||||
],
|
||||
);
|
||||
|
||||
static final darkTheme = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: _accentColor,
|
||||
|
|
|
@ -26,6 +26,16 @@ class Constants {
|
|||
|
||||
static final pointNemo = LatLng(-48.876667, -123.393333);
|
||||
|
||||
static final wonders = [
|
||||
LatLng(29.979167, 31.134167),
|
||||
LatLng(36.451000, 28.223615),
|
||||
LatLng(32.5355, 44.4275),
|
||||
LatLng(31.213889, 29.885556),
|
||||
LatLng(37.0379, 27.4241),
|
||||
LatLng(37.637861, 21.63),
|
||||
LatLng(37.949722, 27.363889),
|
||||
];
|
||||
|
||||
static const int infoGroupMaxValueLength = 140;
|
||||
|
||||
static const List<Dependency> androidDependencies = [
|
||||
|
@ -274,6 +284,11 @@ class Constants {
|
|||
license: 'Apache 2.0',
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Transparent Image',
|
||||
license: 'MIT',
|
||||
sourceUrl: 'https://pub.dev/packages/transparent_image',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Tuple',
|
||||
license: 'BSD 2-Clause',
|
||||
|
|
|
@ -123,17 +123,19 @@ class _AvesAppState extends State<AvesApp> {
|
|||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) {
|
||||
await Firebase.initializeApp().then((app) async {
|
||||
FlutterError.onError = reportService.recordFlutterError;
|
||||
final now = DateTime.now();
|
||||
reportService.setCustomKeys({
|
||||
'locales': window.locales.join(', '),
|
||||
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
await reportService.setCustomKeys({
|
||||
'build_mode': kReleaseMode
|
||||
? 'release'
|
||||
: kProfileMode
|
||||
? 'profile'
|
||||
: 'debug',
|
||||
'has_play_services': hasPlayServices,
|
||||
'locales': window.locales.join(', '),
|
||||
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
||||
});
|
||||
});
|
||||
await settings.init();
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
|
@ -12,20 +12,17 @@ import 'package:aves/model/source/collection_source.dart';
|
|||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/services/app_shortcut_service.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -68,7 +65,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
_canAddShortcutsLoader = AppShortcutService.canPin();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -87,11 +84,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
void _registerWidget(CollectionAppBar widget) {
|
||||
widget.collection.filterChangeNotifier.addListener(_updateHeight);
|
||||
widget.collection.filterChangeNotifier.addListener(_onFilterChanged);
|
||||
}
|
||||
|
||||
void _unregisterWidget(CollectionAppBar widget) {
|
||||
widget.collection.filterChangeNotifier.removeListener(_updateHeight);
|
||||
widget.collection.filterChangeNotifier.removeListener(_onFilterChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -149,7 +146,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
Widget? _buildAppBarTitle(bool isSelecting) {
|
||||
if (isSelecting) {
|
||||
return Selector<Selection<AvesEntry>, int>(
|
||||
selector: (context, selection) => selection.selection.length,
|
||||
selector: (context, selection) => selection.selectedItems.length,
|
||||
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
|
||||
);
|
||||
} else {
|
||||
|
@ -178,9 +175,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
),
|
||||
if (isSelecting)
|
||||
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
|
||||
selector: (context, selection) => selection.selection.isEmpty,
|
||||
selector: (context, selection) => selection.selectedItems.isEmpty,
|
||||
builder: (context, isEmpty, child) => IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
icon: action.getIcon() ?? const SizedBox(),
|
||||
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
|
||||
tooltip: action.getText(context),
|
||||
),
|
||||
|
@ -189,87 +186,83 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
future: _canAddShortcutsLoader,
|
||||
builder: (context, snapshot) {
|
||||
final canAddShortcuts = snapshot.data ?? false;
|
||||
return PopupMenuButton<CollectionAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final isNotEmpty = !collection.isEmpty;
|
||||
final hasSelection = selection.selection.isNotEmpty;
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: const Key('menu-sort'),
|
||||
value: CollectionAction.sort,
|
||||
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
|
||||
),
|
||||
if (collection.sortFactor == EntrySortFactor.date)
|
||||
PopupMenuItem(
|
||||
key: const Key('menu-group'),
|
||||
value: CollectionAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
return MenuIconTheme(
|
||||
child: PopupMenuButton<EntrySetAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final groupable = collection.sortFactor == EntrySortFactor.date;
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final isSelecting = selection.isSelecting;
|
||||
final selectedItems = selection.selectedItems;
|
||||
final hasSelection = selectedItems.isNotEmpty;
|
||||
final hasItems = !collection.isEmpty;
|
||||
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
|
||||
|
||||
return [
|
||||
_toMenuItem(
|
||||
EntrySetAction.sort,
|
||||
key: const Key('menu-sort'),
|
||||
),
|
||||
if (!selection.isSelecting && appMode == AppMode.main) ...[
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.select,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.map,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.stats,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
|
||||
),
|
||||
if (canAddShortcuts)
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.addShortcut,
|
||||
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
|
||||
if (groupable)
|
||||
_toMenuItem(
|
||||
EntrySetAction.group,
|
||||
key: const Key('menu-group'),
|
||||
),
|
||||
],
|
||||
if (selection.isSelecting) ...[
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.copy,
|
||||
enabled: hasSelection,
|
||||
child: MenuRow(text: context.l10n.collectionActionCopy),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.move,
|
||||
enabled: hasSelection,
|
||||
child: MenuRow(text: context.l10n.collectionActionMove),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.refreshMetadata,
|
||||
enabled: hasSelection,
|
||||
child: MenuRow(text: context.l10n.collectionActionRefreshMetadata),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectAll,
|
||||
enabled: selection.selection.length < collection.entryCount,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelectAll),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.selectNone,
|
||||
enabled: hasSelection,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelectNone),
|
||||
),
|
||||
]
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
|
||||
},
|
||||
if (appMode == AppMode.main) ...[
|
||||
if (!isSelecting)
|
||||
_toMenuItem(
|
||||
EntrySetAction.select,
|
||||
enabled: hasItems,
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
if (isSelecting)
|
||||
...[
|
||||
EntrySetAction.copy,
|
||||
EntrySetAction.move,
|
||||
EntrySetAction.refreshMetadata,
|
||||
].map((v) => _toMenuItem(v, enabled: hasSelection)),
|
||||
...[
|
||||
EntrySetAction.map,
|
||||
EntrySetAction.stats,
|
||||
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
|
||||
if (!isSelecting && canAddShortcuts) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(EntrySetAction.addShortcut),
|
||||
],
|
||||
],
|
||||
if (isSelecting) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(
|
||||
EntrySetAction.selectAll,
|
||||
enabled: selectedItems.length < collection.entryCount,
|
||||
),
|
||||
_toMenuItem(
|
||||
EntrySetAction.selectNone,
|
||||
enabled: hasSelection,
|
||||
),
|
||||
]
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {Key? key, bool enabled = true}) {
|
||||
return PopupMenuItem(
|
||||
key: key,
|
||||
value: action,
|
||||
enabled: enabled,
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
);
|
||||
}
|
||||
|
||||
void _onActivityChange() {
|
||||
if (context.read<Selection<AvesEntry>>().isSelecting) {
|
||||
_browseToSelectAnimation.forward();
|
||||
|
@ -278,36 +271,41 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
}
|
||||
|
||||
void _updateHeight() {
|
||||
void _onFilterChanged() {
|
||||
widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0);
|
||||
|
||||
if (hasFilters) {
|
||||
final filters = collection.filters;
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet();
|
||||
selection.removeFromSelection(toRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCollectionActionSelected(CollectionAction action) async {
|
||||
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
|
||||
switch (action) {
|
||||
case CollectionAction.copy:
|
||||
case CollectionAction.move:
|
||||
case CollectionAction.refreshMetadata:
|
||||
case EntrySetAction.copy:
|
||||
case EntrySetAction.move:
|
||||
case EntrySetAction.refreshMetadata:
|
||||
case EntrySetAction.map:
|
||||
case EntrySetAction.stats:
|
||||
_actionDelegate.onCollectionActionSelected(context, action);
|
||||
break;
|
||||
case CollectionAction.select:
|
||||
case EntrySetAction.select:
|
||||
context.read<Selection<AvesEntry>>().select();
|
||||
break;
|
||||
case CollectionAction.selectAll:
|
||||
case EntrySetAction.selectAll:
|
||||
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
|
||||
break;
|
||||
case CollectionAction.selectNone:
|
||||
case EntrySetAction.selectNone:
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
case CollectionAction.map:
|
||||
_goToMap();
|
||||
break;
|
||||
case CollectionAction.stats:
|
||||
_goToStats();
|
||||
break;
|
||||
case CollectionAction.addShortcut:
|
||||
case EntrySetAction.addShortcut:
|
||||
unawaited(_showShortcutDialog(context));
|
||||
break;
|
||||
case CollectionAction.group:
|
||||
case EntrySetAction.group:
|
||||
final value = await showDialog<EntryGroupFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
|
||||
|
@ -327,7 +325,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
settings.collectionSectionFactor = value;
|
||||
}
|
||||
break;
|
||||
case CollectionAction.sort:
|
||||
case EntrySetAction.sort:
|
||||
final value = await showDialog<EntrySortFactor>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<EntrySortFactor>(
|
||||
|
@ -385,30 +383,4 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToMap() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
source: source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToStats() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: StatsPage.routeName),
|
||||
builder: (context) => StatsPage(
|
||||
source: source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import 'package:aves/widgets/collection/app_bar.dart';
|
|||
import 'package:aves/widgets/collection/draggable_thumb_label.dart';
|
||||
import 'package:aves/widgets/collection/grid/section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/thumbnail.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.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';
|
||||
|
@ -27,6 +26,7 @@ import 'package:aves/widgets/common/identity/empty.dart';
|
|||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/actions/collection_actions.dart';
|
||||
import 'package:aves/model/actions/entry_actions.dart';
|
||||
import 'package:aves/model/actions/entry_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
|
@ -22,6 +22,8 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/album_pick.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -41,24 +43,30 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
}
|
||||
}
|
||||
|
||||
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
|
||||
void onCollectionActionSelected(BuildContext context, EntrySetAction action) {
|
||||
switch (action) {
|
||||
case CollectionAction.copy:
|
||||
case EntrySetAction.copy:
|
||||
_moveSelection(context, moveType: MoveType.copy);
|
||||
break;
|
||||
case CollectionAction.move:
|
||||
case EntrySetAction.move:
|
||||
_moveSelection(context, moveType: MoveType.move);
|
||||
break;
|
||||
case CollectionAction.refreshMetadata:
|
||||
case EntrySetAction.refreshMetadata:
|
||||
_refreshMetadata(context);
|
||||
break;
|
||||
case EntrySetAction.map:
|
||||
_goToMap(context);
|
||||
break;
|
||||
case EntrySetAction.stats:
|
||||
_goToStats(context);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
|
||||
return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
|
||||
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet();
|
||||
}
|
||||
|
||||
void _share(BuildContext context) {
|
||||
|
@ -242,4 +250,37 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _goToMap(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read<CollectionLens>().sortedEntries;
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
entries: entries.where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToStats(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final collection = context.read<CollectionLens>();
|
||||
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet();
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: StatsPage.routeName),
|
||||
builder: (context) => StatsPage(
|
||||
entries: entries,
|
||||
source: collection.source,
|
||||
parentCollection: collection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/viewer/entry_viewer_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
47
lib/widgets/common/basic/menu.dart
Normal file
47
lib/widgets/common/basic/menu.dart
Normal file
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final String text;
|
||||
final Widget? icon;
|
||||
|
||||
const MenuRow({
|
||||
Key? key,
|
||||
required this.text,
|
||||
this.icon,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
if (icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: icon,
|
||||
),
|
||||
Expanded(child: Text(text)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// scale icons according to text scale
|
||||
class MenuIconTheme extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const MenuIconTheme({
|
||||
Key? key,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconTheme = IconTheme.of(context);
|
||||
return IconTheme(
|
||||
data: iconTheme.copyWith(
|
||||
size: iconTheme.size! * MediaQuery.textScaleFactorOf(context),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final bool? checked;
|
||||
|
||||
const MenuRow({
|
||||
Key? key,
|
||||
required this.text,
|
||||
this.icon,
|
||||
this.checked,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = IconTheme.of(context).size! * textScaleFactor;
|
||||
return Row(
|
||||
children: [
|
||||
if (checked != null) ...[
|
||||
Opacity(
|
||||
opacity: checked! ? 1 : 0,
|
||||
child: Icon(AIcons.checked, size: iconSize),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: iconSize),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(child: Text(text)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import 'package:aves/model/filters/tag.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -74,7 +74,9 @@ class AvesFilterChip extends StatefulWidget {
|
|||
items: actions
|
||||
.map((action) => PopupMenuItem(
|
||||
value: action,
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
child: MenuIconTheme(
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
|
|
@ -117,17 +117,25 @@ class MultiPageIcon extends StatelessWidget {
|
|||
if (entry.isMotionPhoto) {
|
||||
icon = AIcons.motionPhoto;
|
||||
} else {
|
||||
if(entry.isBurst) {
|
||||
if (entry.isBurst) {
|
||||
text = '${entry.burstEntries?.length}';
|
||||
}
|
||||
icon = AIcons.multiPage;
|
||||
}
|
||||
return OverlayIcon(
|
||||
final gridTheme = context.watch<GridThemeData>();
|
||||
final child = OverlayIcon(
|
||||
icon: icon,
|
||||
size: context.select<GridThemeData, double>((t) => t.iconSize),
|
||||
size: gridTheme.iconSize,
|
||||
iconScale: .8,
|
||||
text: text,
|
||||
);
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade200,
|
||||
fontSize: gridTheme.fontSize,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/blurred.dart';
|
||||
import 'package:aves/widgets/common/fx/borders.dart';
|
||||
import 'package:aves/widgets/common/map/compass.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
|
@ -16,19 +18,23 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapButtonPanel extends StatelessWidget {
|
||||
final LatLng latLng;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final Future<void> Function(double amount)? zoomBy;
|
||||
final VoidCallback? resetRotation;
|
||||
|
||||
static const double padding = 4;
|
||||
|
||||
const MapButtonPanel({
|
||||
Key? key,
|
||||
required this.latLng,
|
||||
required this.boundsNotifier,
|
||||
this.zoomBy,
|
||||
this.resetRotation,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconSize = Size.square(iconTheme.size!);
|
||||
return Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
|
@ -38,53 +44,96 @@ class MapButtonPanel extends StatelessWidget {
|
|||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: Stack(
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: AIcons.openOutside,
|
||||
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
}),
|
||||
tooltip: context.l10n.entryActionOpenMap,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.layers,
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.viewerInfoMapStyleTitle,
|
||||
if (resetRotation != null)
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: ValueListenableBuilder<ZoomedBounds>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, bounds, child) {
|
||||
final degrees = bounds.rotation;
|
||||
return AnimatedOpacity(
|
||||
opacity: degrees == 0 ? 0 : 1,
|
||||
duration: Durations.viewerOverlayAnimation,
|
||||
child: MapOverlayButton(
|
||||
icon: Transform(
|
||||
origin: iconSize.center(Offset.zero),
|
||||
transform: Matrix4.rotationZ(degToRadian(degrees)),
|
||||
child: CustomPaint(
|
||||
painter: CompassPainter(
|
||||
color: iconTheme.color!,
|
||||
),
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
onPressed: () => resetRotation?.call(),
|
||||
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.viewerInfoMapStyleTooltip,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.openOutside),
|
||||
onPressed: () => AndroidAppService.openMap(boundsNotifier.value.center).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
}),
|
||||
tooltip: context.l10n.entryActionOpenMap,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.viewerInfoMapStyleTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.viewerInfoMapStyleTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomIn,
|
||||
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomOut,
|
||||
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -96,7 +145,7 @@ class MapButtonPanel extends StatelessWidget {
|
|||
}
|
||||
|
||||
class MapOverlayButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Widget icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
|
@ -123,7 +172,7 @@ class MapOverlayButton extends StatelessWidget {
|
|||
child: IconButton(
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(icon),
|
||||
icon: icon,
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
|
|
43
lib/widgets/common/map/compass.dart
Normal file
43
lib/widgets/common/map/compass.dart
Normal file
|
@ -0,0 +1,43 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class CompassPainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
const CompassPainter({
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final base = size.width * .3;
|
||||
final height = size.height * .4;
|
||||
|
||||
final northTriangle = Path()
|
||||
..moveTo(center.dx - base / 2, center.dy)
|
||||
..lineTo(center.dx, center.dy - height)
|
||||
..lineTo(center.dx + base / 2, center.dy)
|
||||
..close();
|
||||
final southTriangle = Path()
|
||||
..moveTo(center.dx - base / 2, center.dy)
|
||||
..lineTo(center.dx + base / 2, center.dy)
|
||||
..lineTo(center.dx, center.dy + height)
|
||||
..close();
|
||||
|
||||
final fillPaint = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withOpacity(.6);
|
||||
final strokePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.7
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = color;
|
||||
|
||||
canvas.drawPath(northTriangle, fillPaint);
|
||||
canvas.drawPath(northTriangle, strokePaint);
|
||||
canvas.drawPath(southTriangle, strokePaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
23
lib/widgets/common/map/controller.dart
Normal file
23
lib/widgets/common/map/controller.dart
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AvesMapController {
|
||||
final StreamController _streamController = StreamController.broadcast();
|
||||
|
||||
Stream<dynamic> get _events => _streamController.stream;
|
||||
|
||||
Stream<MapControllerMoveEvent> get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
|
||||
|
||||
void dispose() {
|
||||
_streamController.close();
|
||||
}
|
||||
|
||||
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
|
||||
}
|
||||
|
||||
class MapControllerMoveEvent {
|
||||
final LatLng latLng;
|
||||
|
||||
MapControllerMoveEvent(this.latLng);
|
||||
}
|
|
@ -1,17 +1,23 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/map/attribution.dart';
|
||||
import 'package:aves/widgets/common/map/buttons.dart';
|
||||
import 'package:aves/widgets/common/map/controller.dart';
|
||||
import 'package:aves/widgets/common/map/decorator.dart';
|
||||
import 'package:aves/widgets/common/map/geo_entry.dart';
|
||||
import 'package:aves/widgets/common/map/google/map.dart';
|
||||
import 'package:aves/widgets/common/map/leaflet/map.dart';
|
||||
import 'package:aves/widgets/common/map/marker.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:fluster/fluster.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -19,22 +25,26 @@ import 'package:flutter/material.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
|
||||
class GeoMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final List<AvesEntry> entries;
|
||||
final bool interactive;
|
||||
final double? mapHeight;
|
||||
final ValueNotifier<bool> isAnimatingNotifier;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final MarkerTapCallback? onMarkerTap;
|
||||
|
||||
static const markerImageExtent = 48.0;
|
||||
static const pointerSize = Size(8, 6);
|
||||
|
||||
const GeoMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.entries,
|
||||
required this.interactive,
|
||||
this.mapHeight,
|
||||
required this.isAnimatingNotifier,
|
||||
this.onUserZoomChange,
|
||||
this.onMarkerTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -47,7 +57,9 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
// it is especially severe the first time, but still significant afterwards
|
||||
// so we prevent loading it while scrolling or animating
|
||||
bool _googleMapsLoaded = false;
|
||||
late ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
late final ValueNotifier<ZoomedBounds> _boundsNotifier;
|
||||
late final Fluster<GeoEntry> _defaultMarkerCluster;
|
||||
Fluster<GeoEntry>? _slowMarkerCluster;
|
||||
|
||||
List<AvesEntry> get entries => widget.entries;
|
||||
|
||||
|
@ -58,36 +70,49 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints(
|
||||
points: entries.map((v) => v.latLng!).toSet(),
|
||||
final points = entries.map((v) => v.latLng!).toSet();
|
||||
_boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints(
|
||||
points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]},
|
||||
collocationZoom: settings.infoMapZoom,
|
||||
));
|
||||
_defaultMarkerCluster = _buildFluster();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final markers = entries.map((entry) {
|
||||
var latLng = entry.latLng!;
|
||||
return GeoEntry(
|
||||
entry: entry,
|
||||
latitude: latLng.latitude,
|
||||
longitude: latLng.longitude,
|
||||
markerId: entry.uri,
|
||||
);
|
||||
}).toList();
|
||||
final markerCluster = Fluster<GeoEntry>(
|
||||
// we keep clustering on the whole range of zooms (including the maximum)
|
||||
// to avoid collocated entries overlapping
|
||||
minZoom: 0,
|
||||
maxZoom: 22,
|
||||
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
|
||||
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
|
||||
radius: 240,
|
||||
extent: 2 << 9,
|
||||
nodeSize: 64,
|
||||
points: markers,
|
||||
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
|
||||
);
|
||||
void _onMarkerTap(GeoEntry geoEntry) {
|
||||
final onTap = widget.onMarkerTap;
|
||||
if (onTap == null) return;
|
||||
|
||||
final clusterId = geoEntry.clusterId;
|
||||
Set<AvesEntry> getClusterEntries() {
|
||||
if (clusterId == null) {
|
||||
return {geoEntry.entry!};
|
||||
}
|
||||
|
||||
var points = _defaultMarkerCluster.points(clusterId);
|
||||
if (points.length != geoEntry.pointsSize) {
|
||||
// `Fluster.points()` method does not always return all the points contained in a cluster
|
||||
// the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`)
|
||||
_slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length));
|
||||
points = _slowMarkerCluster!.points(clusterId);
|
||||
assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry');
|
||||
}
|
||||
return points.map((geoEntry) => geoEntry.entry!).toSet();
|
||||
}
|
||||
|
||||
AvesEntry? markerEntry;
|
||||
if (clusterId != null) {
|
||||
final uri = geoEntry.childMarkerId;
|
||||
markerEntry = entries.firstWhereOrNull((v) => v.uri == uri);
|
||||
} else {
|
||||
markerEntry = geoEntry.entry;
|
||||
}
|
||||
|
||||
if (markerEntry != null) {
|
||||
onTap(markerEntry, getClusterEntries);
|
||||
}
|
||||
}
|
||||
|
||||
return FutureBuilder<bool>(
|
||||
future: availability.isConnected,
|
||||
|
@ -98,7 +123,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
builder: (context, mapStyle, child) {
|
||||
final isGoogleMaps = mapStyle.isGoogleMaps;
|
||||
final progressive = !isGoogleMaps;
|
||||
Widget _buildMarker(MarkerKey key) => ImageMarker(
|
||||
Widget _buildMarkerWidget(MarkerKey key) => ImageMarker(
|
||||
key: key,
|
||||
entry: key.entry,
|
||||
count: key.count,
|
||||
|
@ -109,26 +134,32 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
|
||||
Widget child = isGoogleMaps
|
||||
? EntryGoogleMap(
|
||||
boundsNotifier: boundsNotifier,
|
||||
controller: widget.controller,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
interactive: interactive,
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
style: mapStyle,
|
||||
markerBuilder: _buildMarker,
|
||||
markerCluster: markerCluster,
|
||||
markerEntries: entries,
|
||||
markerClusterBuilder: _buildMarkerClusters,
|
||||
markerWidgetBuilder: _buildMarkerWidget,
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
)
|
||||
: EntryLeafletMap(
|
||||
boundsNotifier: boundsNotifier,
|
||||
controller: widget.controller,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
interactive: interactive,
|
||||
minZoom: 2,
|
||||
maxZoom: 16,
|
||||
style: mapStyle,
|
||||
markerBuilder: _buildMarker,
|
||||
markerCluster: markerCluster,
|
||||
markerEntries: entries,
|
||||
markerClusterBuilder: _buildMarkerClusters,
|
||||
markerWidgetBuilder: _buildMarkerWidget,
|
||||
markerSize: Size(
|
||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
|
||||
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
|
||||
),
|
||||
onUserZoomChange: widget.onUserZoomChange,
|
||||
onMarkerTap: _onMarkerTap,
|
||||
);
|
||||
|
||||
child = Column(
|
||||
|
@ -161,7 +192,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
interactive: interactive,
|
||||
),
|
||||
MapButtonPanel(
|
||||
latLng: boundsNotifier.value.center,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -185,6 +216,46 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {
|
||||
final markers = entries.map((entry) {
|
||||
final latLng = entry.latLng!;
|
||||
return GeoEntry(
|
||||
entry: entry,
|
||||
latitude: latLng.latitude,
|
||||
longitude: latLng.longitude,
|
||||
markerId: entry.uri,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return Fluster<GeoEntry>(
|
||||
// we keep clustering on the whole range of zooms (including the maximum)
|
||||
// to avoid collocated entries overlapping
|
||||
minZoom: 0,
|
||||
maxZoom: 22,
|
||||
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
|
||||
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
|
||||
radius: 240,
|
||||
extent: 2 << 9,
|
||||
// node size: 64 by default, higher means faster indexing but slower search
|
||||
nodeSize: nodeSize,
|
||||
points: markers,
|
||||
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
|
||||
);
|
||||
}
|
||||
|
||||
Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
|
||||
final bounds = _boundsNotifier.value;
|
||||
final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round());
|
||||
return Map.fromEntries(geoEntries.map((v) {
|
||||
if (v.isCluster!) {
|
||||
final uri = v.childMarkerId;
|
||||
final entry = entries.firstWhere((v) => v.uri == uri);
|
||||
return MapEntry(MarkerKey(entry, v.pointsSize), v);
|
||||
}
|
||||
return MapEntry(MarkerKey(v.entry!, null), v);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
|
@ -198,5 +269,7 @@ class MarkerKey extends LocalKey with EquatableMixin {
|
|||
const MarkerKey(this.entry, this.count);
|
||||
}
|
||||
|
||||
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
|
||||
typedef MarkerClusterBuilder = Map<MarkerKey, GeoEntry> Function();
|
||||
typedef MarkerWidgetBuilder = Widget Function(MarkerKey key);
|
||||
typedef UserZoomChangeCallback = void Function(double zoom);
|
||||
typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set<AvesEntry> Function() getClusterEntries);
|
||||
|
|
|
@ -1,40 +1,43 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/entry_images.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/map/buttons.dart';
|
||||
import 'package:aves/widgets/common/map/controller.dart';
|
||||
import 'package:aves/widgets/common/map/decorator.dart';
|
||||
import 'package:aves/widgets/common/map/geo_entry.dart';
|
||||
import 'package:aves/widgets/common/map/geo_map.dart';
|
||||
import 'package:aves/widgets/common/map/google/marker_generator.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fluster/fluster.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
|
||||
class EntryGoogleMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final double? minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> markerCluster;
|
||||
final List<AvesEntry> markerEntries;
|
||||
final MarkerClusterBuilder markerClusterBuilder;
|
||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||
|
||||
const EntryGoogleMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.boundsNotifier,
|
||||
required this.interactive,
|
||||
this.minZoom,
|
||||
this.maxZoom,
|
||||
required this.style,
|
||||
required this.markerBuilder,
|
||||
required this.markerCluster,
|
||||
required this.markerEntries,
|
||||
required this.markerClusterBuilder,
|
||||
required this.markerWidgetBuilder,
|
||||
this.onUserZoomChange,
|
||||
this.onMarkerTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -42,7 +45,9 @@ class EntryGoogleMap extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObserver {
|
||||
GoogleMapController? _controller;
|
||||
GoogleMapController? _googleMapController;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
|
||||
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
|
||||
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
|
||||
|
||||
|
@ -50,28 +55,45 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
|
||||
ZoomedBounds get bounds => boundsNotifier.value;
|
||||
|
||||
bool get interactive => widget.interactive;
|
||||
|
||||
static const uninitializedLatLng = LatLng(0, 0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance!.addObserver(this);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EntryGoogleMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
const eq = DeepCollectionEquality();
|
||||
if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) {
|
||||
_markerBitmaps.clear();
|
||||
}
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_unregisterWidget(widget);
|
||||
_googleMapController?.dispose();
|
||||
WidgetsBinding.instance!.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(EntryGoogleMap widget) {
|
||||
final avesMapController = widget.controller;
|
||||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
|
||||
}
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryGoogleMap widget) {
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
|
@ -82,64 +104,51 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
case AppLifecycleState.resumed:
|
||||
// workaround for blank Google Maps when resuming app
|
||||
// cf https://github.com/flutter/flutter/issues/40284
|
||||
_controller?.setMapStyle(null);
|
||||
_googleMapController?.setMapStyle(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ZoomedBounds?>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, visibleRegion, child) {
|
||||
final allEntries = widget.markerEntries;
|
||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
|
||||
if (v.isCluster!) {
|
||||
final uri = v.childMarkerId;
|
||||
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
||||
return MapEntry(MarkerKey(entry, v.pointsSize), v);
|
||||
}
|
||||
return MapEntry(MarkerKey(v.entry!, null), v);
|
||||
}));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MarkerGeneratorWidget<MarkerKey>(
|
||||
markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(),
|
||||
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
|
||||
onRendered: (key, bitmap) {
|
||||
_markerBitmaps[key] = bitmap;
|
||||
_markerBitmapChangeNotifier.notifyListeners();
|
||||
},
|
||||
),
|
||||
MapDecorator(
|
||||
interactive: widget.interactive,
|
||||
child: _buildMap(clusterByMarkerKey),
|
||||
),
|
||||
MapButtonPanel(
|
||||
latLng: bounds.center,
|
||||
zoomBy: _zoomBy,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
return Stack(
|
||||
children: [
|
||||
MarkerGeneratorWidget<MarkerKey>(
|
||||
markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(),
|
||||
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
|
||||
onRendered: (key, bitmap) {
|
||||
_markerBitmaps[key] = bitmap;
|
||||
_markerBitmapChangeNotifier.notifyListeners();
|
||||
},
|
||||
),
|
||||
MapDecorator(
|
||||
interactive: interactive,
|
||||
child: _buildMap(),
|
||||
),
|
||||
MapButtonPanel(
|
||||
boundsNotifier: boundsNotifier,
|
||||
zoomBy: _zoomBy,
|
||||
resetRotation: interactive ? _resetRotation : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
|
||||
Widget _buildMap() {
|
||||
return AnimatedBuilder(
|
||||
animation: _markerBitmapChangeNotifier,
|
||||
builder: (context, child) {
|
||||
final markers = <Marker>{};
|
||||
clusterByMarkerKey.forEach((markerKey, cluster) {
|
||||
_geoEntryByMarkerKey.forEach((markerKey, geoEntry) {
|
||||
final bytes = _markerBitmaps[markerKey];
|
||||
if (bytes != null) {
|
||||
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
|
||||
final point = LatLng(geoEntry.latitude!, geoEntry.longitude!);
|
||||
markers.add(Marker(
|
||||
markerId: MarkerId(cluster.markerId!),
|
||||
markerId: MarkerId(geoEntry.markerId!),
|
||||
consumeTapEvents: true,
|
||||
icon: BitmapDescriptor.fromBytes(bytes),
|
||||
position: latLng,
|
||||
position: point,
|
||||
onTap: () => widget.onMarkerTap?.call(geoEntry),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
@ -150,17 +159,18 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) {
|
||||
_controller = controller;
|
||||
controller.getZoomLevel().then(_updateVisibleRegion);
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: 0);
|
||||
setState(() {});
|
||||
},
|
||||
// TODO TLAD [map] add common compass button for both google/leaflet
|
||||
// compass disabled to use provider agnostic controls
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
mapType: _toMapType(widget.style),
|
||||
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
|
||||
rotateGesturesEnabled: false,
|
||||
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
|
||||
rotateGesturesEnabled: true,
|
||||
scrollGesturesEnabled: interactive,
|
||||
// zoom controls disabled to use provider agnostic controls
|
||||
zoomControlsEnabled: false,
|
||||
|
@ -172,14 +182,22 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: markers,
|
||||
onCameraMove: (position) => _updateVisibleRegion(position.zoom),
|
||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||
onCameraIdle: _updateClusters,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateVisibleRegion(double zoom) async {
|
||||
final bounds = await _controller?.getVisibleRegion();
|
||||
void _updateClusters() {
|
||||
if (!mounted) return;
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
Future<void> _updateVisibleRegion({required double zoom, required double rotation}) async {
|
||||
if (!mounted) return;
|
||||
|
||||
final bounds = await _googleMapController?.getVisibleRegion();
|
||||
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
|
||||
boundsNotifier.value = ZoomedBounds(
|
||||
west: bounds.southwest.longitude,
|
||||
|
@ -187,25 +205,42 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
east: bounds.northeast.longitude,
|
||||
north: bounds.northeast.latitude,
|
||||
zoom: zoom,
|
||||
rotation: rotation,
|
||||
);
|
||||
} else {
|
||||
// the visible region is sometimes uninitialized when queried right after creation,
|
||||
// so we query it again next frame
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_updateVisibleRegion(zoom);
|
||||
_updateVisibleRegion(zoom: zoom, rotation: rotation);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetRotation() async {
|
||||
final controller = _googleMapController;
|
||||
if (controller == null) return;
|
||||
|
||||
await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
)));
|
||||
}
|
||||
|
||||
Future<void> _zoomBy(double amount) async {
|
||||
final controller = _controller;
|
||||
final controller = _googleMapController;
|
||||
if (controller == null) return;
|
||||
|
||||
widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount);
|
||||
await controller.animateCamera(CameraUpdate.zoomBy(amount));
|
||||
}
|
||||
|
||||
Future<void> _moveTo(LatLng point) async {
|
||||
final controller = _googleMapController;
|
||||
if (controller == null) return;
|
||||
|
||||
await controller.animateCamera(CameraUpdate.newLatLng(point));
|
||||
}
|
||||
|
||||
// `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package
|
||||
LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude);
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/map/buttons.dart';
|
||||
import 'package:aves/widgets/common/map/controller.dart';
|
||||
import 'package:aves/widgets/common/map/decorator.dart';
|
||||
import 'package:aves/widgets/common/map/geo_entry.dart';
|
||||
import 'package:aves/widgets/common/map/geo_map.dart';
|
||||
|
@ -10,31 +12,35 @@ import 'package:aves/widgets/common/map/latlng_tween.dart';
|
|||
import 'package:aves/widgets/common/map/leaflet/scale_layer.dart';
|
||||
import 'package:aves/widgets/common/map/leaflet/tile_layers.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
|
||||
import 'package:fluster/fluster.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class EntryLeafletMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final bool interactive;
|
||||
final double minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
final EntryMarkerBuilder markerBuilder;
|
||||
final Fluster<GeoEntry> markerCluster;
|
||||
final List<AvesEntry> markerEntries;
|
||||
final MarkerClusterBuilder markerClusterBuilder;
|
||||
final MarkerWidgetBuilder markerWidgetBuilder;
|
||||
final Size markerSize;
|
||||
final UserZoomChangeCallback? onUserZoomChange;
|
||||
final void Function(GeoEntry geoEntry)? onMarkerTap;
|
||||
|
||||
const EntryLeafletMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.boundsNotifier,
|
||||
required this.interactive,
|
||||
this.minZoom = 0,
|
||||
this.maxZoom = 22,
|
||||
required this.style,
|
||||
required this.markerBuilder,
|
||||
required this.markerCluster,
|
||||
required this.markerEntries,
|
||||
required this.markerClusterBuilder,
|
||||
required this.markerWidgetBuilder,
|
||||
required this.markerSize,
|
||||
this.onUserZoomChange,
|
||||
this.onMarkerTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -42,81 +48,84 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
|
||||
final MapController _mapController = MapController();
|
||||
final MapController _leafletMapController = MapController();
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
|
||||
final Debouncer _debouncer = Debouncer(delay: Durations.mapIdleDebounceDelay);
|
||||
|
||||
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
|
||||
|
||||
ZoomedBounds get bounds => boundsNotifier.value;
|
||||
|
||||
bool get interactive => widget.interactive;
|
||||
|
||||
// duration should match the uncustomizable Google Maps duration
|
||||
static const _cameraAnimationDuration = Duration(milliseconds: 400);
|
||||
static const _zoomMin = 1.0;
|
||||
|
||||
// TODO TLAD [map] also limit zoom on pinch-to-zoom gesture
|
||||
static const _zoomMax = 16.0;
|
||||
|
||||
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
|
||||
static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate;
|
||||
static const _cameraAnimationDuration = Duration(milliseconds: 600);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion()));
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EntryLeafletMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(EntryLeafletMap widget) {
|
||||
final avesMapController = widget.controller;
|
||||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng)));
|
||||
}
|
||||
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
|
||||
boundsNotifier.addListener(_onBoundsChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryLeafletMap widget) {
|
||||
boundsNotifier.removeListener(_onBoundsChange);
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ZoomedBounds?>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, visibleRegion, child) {
|
||||
final allEntries = widget.markerEntries;
|
||||
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
|
||||
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
|
||||
if (v.isCluster!) {
|
||||
final uri = v.childMarkerId;
|
||||
final entry = allEntries.firstWhere((v) => v.uri == uri);
|
||||
return MapEntry(MarkerKey(entry, v.pointsSize), v);
|
||||
}
|
||||
return MapEntry(MarkerKey(v.entry!, null), v);
|
||||
}));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MapDecorator(
|
||||
interactive: widget.interactive,
|
||||
child: _buildMap(clusterByMarkerKey),
|
||||
),
|
||||
MapButtonPanel(
|
||||
latLng: bounds.center,
|
||||
zoomBy: _zoomBy,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
return Stack(
|
||||
children: [
|
||||
MapDecorator(
|
||||
interactive: interactive,
|
||||
child: _buildMap(),
|
||||
),
|
||||
MapButtonPanel(
|
||||
boundsNotifier: boundsNotifier,
|
||||
zoomBy: _zoomBy,
|
||||
resetRotation: interactive ? _resetRotation : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
|
||||
Widget _buildMap() {
|
||||
final markerSize = widget.markerSize;
|
||||
final markers = clusterByMarkerKey.entries.map((kv) {
|
||||
final markers = _geoEntryByMarkerKey.entries.map((kv) {
|
||||
final markerKey = kv.key;
|
||||
final cluster = kv.value;
|
||||
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
|
||||
final geoEntry = kv.value;
|
||||
final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!);
|
||||
return Marker(
|
||||
point: latLng,
|
||||
builder: (context) => GestureDetector(
|
||||
onTap: () => _moveTo(latLng),
|
||||
child: widget.markerBuilder(markerKey),
|
||||
onTap: () => widget.onMarkerTap?.call(geoEntry),
|
||||
child: widget.markerWidgetBuilder(markerKey),
|
||||
),
|
||||
width: markerSize.width,
|
||||
height: markerSize.height,
|
||||
|
@ -128,14 +137,19 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
options: MapOptions(
|
||||
center: bounds.center,
|
||||
zoom: bounds.zoom,
|
||||
interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none,
|
||||
minZoom: widget.minZoom,
|
||||
maxZoom: widget.maxZoom,
|
||||
interactiveFlags: widget.interactive ? InteractiveFlag.all : InteractiveFlag.none,
|
||||
controller: _leafletMapController,
|
||||
),
|
||||
mapController: _mapController,
|
||||
children: [
|
||||
_buildMapLayer(),
|
||||
mapController: _leafletMapController,
|
||||
nonRotatedChildren: [
|
||||
ScaleLayerWidget(
|
||||
options: ScaleLayerOptions(),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
_buildMapLayer(),
|
||||
MarkerLayerWidget(
|
||||
options: MarkerLayerOptions(
|
||||
markers: markers,
|
||||
|
@ -160,30 +174,43 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
}
|
||||
}
|
||||
|
||||
void _onBoundsChange() => _debouncer(_updateClusters);
|
||||
|
||||
void _updateClusters() {
|
||||
if (!mounted) return;
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
void _updateVisibleRegion() {
|
||||
final bounds = _mapController.bounds;
|
||||
final bounds = _leafletMapController.bounds;
|
||||
if (bounds != null) {
|
||||
boundsNotifier.value = ZoomedBounds(
|
||||
west: bounds.west,
|
||||
south: bounds.south,
|
||||
east: bounds.east,
|
||||
north: bounds.north,
|
||||
zoom: _mapController.zoom,
|
||||
zoom: _leafletMapController.zoom,
|
||||
rotation: _leafletMapController.rotation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetRotation() async {
|
||||
final rotationTween = Tween<double>(begin: _leafletMapController.rotation, end: 0);
|
||||
await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _zoomBy(double amount) async {
|
||||
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
|
||||
final endZoom = (_leafletMapController.zoom + amount).clamp(widget.minZoom, widget.maxZoom);
|
||||
widget.onUserZoomChange?.call(endZoom);
|
||||
|
||||
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
|
||||
final zoomTween = Tween<double>(begin: _leafletMapController.zoom, end: endZoom);
|
||||
await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation)));
|
||||
}
|
||||
|
||||
Future<void> _moveTo(LatLng point) async {
|
||||
final centerTween = LatLngTween(begin: _mapController.center, end: point);
|
||||
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
|
||||
final centerTween = LatLngTween(begin: _leafletMapController.center, end: point);
|
||||
await _animateCamera((animation) => _leafletMapController.move(centerTween.evaluate(animation)!, _leafletMapController.zoom));
|
||||
}
|
||||
|
||||
Future<void> _animateCamera(void Function(Animation<double> animation) animate) async {
|
||||
|
|
|
@ -23,7 +23,6 @@ class ScaleLayerOptions extends LayerOptions {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO TLAD [map] scale bar should not rotate together with map layer
|
||||
class ScaleLayerWidget extends StatelessWidget {
|
||||
final ScaleLayerOptions options;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
|
|
@ -6,14 +6,14 @@ import 'package:latlong2/latlong.dart';
|
|||
|
||||
@immutable
|
||||
class ZoomedBounds extends Equatable {
|
||||
final double west, south, east, north, zoom;
|
||||
final double west, south, east, north, zoom, rotation;
|
||||
|
||||
List<double> get boundingBox => [west, south, east, north];
|
||||
|
||||
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [west, south, east, north, zoom];
|
||||
List<Object?> get props => [west, south, east, north, zoom, rotation];
|
||||
|
||||
const ZoomedBounds({
|
||||
required this.west,
|
||||
|
@ -21,6 +21,7 @@ class ZoomedBounds extends Equatable {
|
|||
required this.east,
|
||||
required this.north,
|
||||
required this.zoom,
|
||||
required this.rotation,
|
||||
});
|
||||
|
||||
static const _collocationMaxDeltaThreshold = 360 / (2 << 19);
|
||||
|
@ -59,6 +60,7 @@ class ZoomedBounds extends Equatable {
|
|||
east: east,
|
||||
north: north,
|
||||
zoom: zoom,
|
||||
rotation: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
|
||||
import 'package:aves/widgets/common/fx/borders.dart';
|
||||
import 'package:aves/widgets/common/grid/overlay.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/overlay.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DecoratedThumbnail extends StatelessWidget {
|
|
@ -8,11 +8,11 @@ import 'package:aves/model/settings/entry_background.dart';
|
|||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/error.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/checkered_decoration.dart';
|
||||
import 'package:aves/widgets/common/fx/transition_image.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/error.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
177
lib/widgets/common/thumbnail/scroller.dart
Normal file
177
lib/widgets/common/thumbnail/scroller.dart
Normal file
|
@ -0,0 +1,177 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/grid/theme.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/decorated.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ThumbnailScroller extends StatefulWidget {
|
||||
final double availableWidth;
|
||||
final int entryCount;
|
||||
final AvesEntry? Function(int index) entryBuilder;
|
||||
final ValueNotifier<int?> indexNotifier;
|
||||
|
||||
const ThumbnailScroller({
|
||||
Key? key,
|
||||
required this.availableWidth,
|
||||
required this.entryCount,
|
||||
required this.entryBuilder,
|
||||
required this.indexNotifier,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_ThumbnailScrollerState createState() => _ThumbnailScrollerState();
|
||||
}
|
||||
|
||||
class _ThumbnailScrollerState extends State<ThumbnailScroller> {
|
||||
final _cancellableNotifier = ValueNotifier(true);
|
||||
late ScrollController _scrollController;
|
||||
bool _isAnimating = false, _isScrolling = false;
|
||||
|
||||
static const double extent = 48;
|
||||
static const double separatorWidth = 2;
|
||||
|
||||
int get entryCount => widget.entryCount;
|
||||
|
||||
ValueNotifier<int?> get indexNotifier => widget.indexNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registerWidget(widget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ThumbnailScroller oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.indexNotifier != widget.indexNotifier) {
|
||||
_unregisterWidget(oldWidget);
|
||||
_registerWidget(widget);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerWidget(ThumbnailScroller widget) {
|
||||
final scrollOffset = indexToScrollOffset(indexNotifier.value ?? 0);
|
||||
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
|
||||
_scrollController.addListener(_onScrollChange);
|
||||
widget.indexNotifier.addListener(_onIndexChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(ThumbnailScroller widget) {
|
||||
_scrollController.removeListener(_onScrollChange);
|
||||
_scrollController.dispose();
|
||||
widget.indexNotifier.removeListener(_onIndexChange);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth);
|
||||
final horizontalMargin = SizedBox(width: marginWidth);
|
||||
const separator = SizedBox(width: separatorWidth);
|
||||
|
||||
return GridTheme(
|
||||
extent: extent,
|
||||
showLocation: false,
|
||||
child: SizedBox(
|
||||
height: extent,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: _scrollController,
|
||||
// default padding in scroll direction matches `MediaQuery.viewPadding`,
|
||||
// but we already accommodate for it, so make sure horizontal padding is 0
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 || index == entryCount + 1) return horizontalMargin;
|
||||
final page = index - 1;
|
||||
final pageEntry = widget.entryBuilder(page);
|
||||
if (pageEntry == null) return const SizedBox();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => indexNotifier.value = page,
|
||||
child: DecoratedThumbnail(
|
||||
entry: pageEntry,
|
||||
tileExtent: extent,
|
||||
// the retrieval task queue can pile up for thumbnails of heavy pages
|
||||
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
|
||||
// so we cancel these requests when possible
|
||||
cancellableNotifier: _cancellableNotifier,
|
||||
selectable: false,
|
||||
highlightable: false,
|
||||
hero: false,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: ValueListenableBuilder<int?>(
|
||||
valueListenable: indexNotifier,
|
||||
builder: (context, currentIndex, child) {
|
||||
return AnimatedContainer(
|
||||
color: currentIndex == page ? Colors.transparent : Colors.black45,
|
||||
width: extent,
|
||||
height: extent,
|
||||
duration: Durations.thumbnailScrollerShadeAnimation,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => separator,
|
||||
itemCount: entryCount + 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _goTo(int index) async {
|
||||
final targetOffset = indexToScrollOffset(index);
|
||||
final offsetDelta = (targetOffset - _scrollController.offset).abs();
|
||||
|
||||
if (offsetDelta > widget.availableWidth * 2) {
|
||||
_scrollController.jumpTo(targetOffset);
|
||||
} else {
|
||||
_isAnimating = true;
|
||||
await _scrollController.animateTo(
|
||||
targetOffset,
|
||||
duration: Durations.thumbnailScrollerScrollAnimation,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_isAnimating = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onScrollChange() {
|
||||
if (!_isAnimating) {
|
||||
final index = scrollOffsetToIndex(_scrollController.offset);
|
||||
if (indexNotifier.value != index) {
|
||||
_isScrolling = true;
|
||||
indexNotifier.value = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onIndexChange() {
|
||||
if (!_isScrolling && !_isAnimating) {
|
||||
final index = indexNotifier.value;
|
||||
if (index != null) {
|
||||
_goTo(index);
|
||||
}
|
||||
}
|
||||
_isScrolling = false;
|
||||
}
|
||||
|
||||
double indexToScrollOffset(int index) => index * (extent + separatorWidth);
|
||||
|
||||
int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round();
|
||||
}
|
|
@ -15,7 +15,7 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Consumer<Settings>(
|
||||
builder: (context, settings, child) {
|
||||
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
|
||||
String toMultiline(Iterable? l) => l != null && l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
|
||||
return AvesExpansionTile(
|
||||
title: 'Settings',
|
||||
children: [
|
||||
|
@ -54,6 +54,9 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
'infoMapZoom': '${settings.infoMapZoom}',
|
||||
'viewerQuickActions': '${settings.viewerQuickActions}',
|
||||
'videoQuickActions': '${settings.videoQuickActions}',
|
||||
'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks),
|
||||
'drawerAlbumBookmarks': toMultiline(settings.drawerAlbumBookmarks),
|
||||
'drawerPageBookmarks': toMultiline(settings.drawerPageBookmarks),
|
||||
'pinnedFilters': toMultiline(settings.pinnedFilters),
|
||||
'hiddenFilters': toMultiline(settings.hiddenFilters),
|
||||
'searchHistory': toMultiline(settings.searchHistory),
|
||||
|
|
|
@ -2,9 +2,9 @@ import 'package:aves/model/covers.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
|
@ -77,9 +77,9 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
|
|||
title,
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: _isCustom ? _pickEntry : null,
|
||||
tooltip: 'Change',
|
||||
icon: const Icon(AIcons.setCover),
|
||||
onPressed: _isCustom ? _pickEntry : null,
|
||||
tooltip: context.l10n.changeTooltip,
|
||||
),
|
||||
])
|
||||
: title,
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||
import 'package:aves/widgets/drawer/collection_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AlbumTile extends StatelessWidget {
|
||||
final String album;
|
||||
|
||||
const AlbumTile({
|
||||
Key? key,
|
||||
required this.album,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final displayName = source.getAlbumDisplayName(context, album);
|
||||
return CollectionNavTile(
|
||||
leading: IconUtils.getAlbumIcon(
|
||||
context: context,
|
||||
albumPath: album,
|
||||
),
|
||||
title: displayName,
|
||||
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||
? const Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: null,
|
||||
filter: AlbumFilter(album, displayName),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
|
@ -16,8 +14,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_logo.dart';
|
||||
import 'package:aves/widgets/debug/app_debug_page.dart';
|
||||
import 'package:aves/widgets/drawer/album_tile.dart';
|
||||
import 'package:aves/widgets/drawer/collection_tile.dart';
|
||||
import 'package:aves/widgets/drawer/collection_nav_tile.dart';
|
||||
import 'package:aves/widgets/drawer/page_nav_tile.dart';
|
||||
import 'package:aves/widgets/drawer/tile.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
|
@ -32,6 +30,16 @@ class AppDrawer extends StatefulWidget {
|
|||
|
||||
@override
|
||||
_AppDrawerState createState() => _AppDrawerState();
|
||||
|
||||
static List<String> getDefaultAlbums(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final specialAlbums = source.rawAlbums.where((album) {
|
||||
final type = androidFileUtils.getAlbumType(album);
|
||||
return [AlbumType.camera, AlbumType.screenshots].contains(type);
|
||||
}).toList()
|
||||
..sort(source.compareAlbumsByName);
|
||||
return specialAlbums;
|
||||
}
|
||||
}
|
||||
|
||||
class _AppDrawerState extends State<AppDrawer> {
|
||||
|
@ -47,19 +55,11 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
final showVideos = !hiddenFilters.contains(MimeFilter.video);
|
||||
final showFavourites = !hiddenFilters.contains(FavouriteFilter.instance);
|
||||
final drawerItems = <Widget>[
|
||||
_buildHeader(context),
|
||||
allCollectionTile,
|
||||
if (showVideos) videoTile,
|
||||
if (showFavourites) favouriteTile,
|
||||
_buildSpecialAlbumSection(),
|
||||
const Divider(),
|
||||
albumListTile,
|
||||
countryListTile,
|
||||
tagListTile,
|
||||
..._buildTypeLinks(),
|
||||
_buildAlbumLinks(),
|
||||
..._buildPageLinks(),
|
||||
if (!kReleaseMode) ...[
|
||||
const Divider(),
|
||||
debugTile,
|
||||
|
@ -192,82 +192,77 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecialAlbumSection() {
|
||||
List<Widget> _buildTypeLinks() {
|
||||
final hiddenFilters = settings.hiddenFilters;
|
||||
final typeBookmarks = settings.drawerTypeBookmarks;
|
||||
return typeBookmarks
|
||||
.where((filter) => !hiddenFilters.contains(filter))
|
||||
.map((filter) => CollectionNavTile(
|
||||
leading: DrawerFilterIcon(filter: filter),
|
||||
title: DrawerFilterTitle(filter: filter),
|
||||
filter: filter,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Widget _buildAlbumLinks() {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) {
|
||||
final specialAlbums = source.rawAlbums.where((album) {
|
||||
final type = androidFileUtils.getAlbumType(album);
|
||||
return [AlbumType.camera, AlbumType.screenshots].contains(type);
|
||||
}).toList()
|
||||
..sort(source.compareAlbumsByName);
|
||||
|
||||
if (specialAlbums.isEmpty) return const SizedBox.shrink();
|
||||
final albums = settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context);
|
||||
if (albums.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(),
|
||||
...specialAlbums.map((album) => AlbumTile(album: album)),
|
||||
...albums.map((album) => AlbumNavTile(album: album)),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// tiles
|
||||
List<Widget> _buildPageLinks() {
|
||||
final pageBookmarks = settings.drawerPageBookmarks;
|
||||
if (pageBookmarks.isEmpty) return [];
|
||||
|
||||
Widget get allCollectionTile => CollectionNavTile(
|
||||
leading: const Icon(AIcons.allCollection),
|
||||
title: context.l10n.drawerCollectionAll,
|
||||
filter: null,
|
||||
);
|
||||
return [
|
||||
const Divider(),
|
||||
...pageBookmarks.map((route) {
|
||||
WidgetBuilder? pageBuilder;
|
||||
Widget? trailing;
|
||||
switch (route) {
|
||||
case AlbumListPage.routeName:
|
||||
pageBuilder = (_) => const AlbumListPage();
|
||||
trailing = StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.rawAlbums.length}'),
|
||||
);
|
||||
break;
|
||||
case CountryListPage.routeName:
|
||||
pageBuilder = (_) => const CountryListPage();
|
||||
trailing = StreamBuilder(
|
||||
stream: source.eventBus.on<CountriesChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.sortedCountries.length}'),
|
||||
);
|
||||
break;
|
||||
case TagListPage.routeName:
|
||||
pageBuilder = (_) => const TagListPage();
|
||||
trailing = StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.sortedTags.length}'),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
Widget get videoTile => CollectionNavTile(
|
||||
leading: const Icon(AIcons.video),
|
||||
title: context.l10n.drawerCollectionVideos,
|
||||
filter: MimeFilter.video,
|
||||
);
|
||||
return PageNavTile(
|
||||
trailing: trailing,
|
||||
routeName: route,
|
||||
pageBuilder: pageBuilder ?? (_) => const SizedBox(),
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
Widget get favouriteTile => CollectionNavTile(
|
||||
leading: const Icon(AIcons.favourite),
|
||||
title: context.l10n.drawerCollectionFavourites,
|
||||
filter: FavouriteFilter.instance,
|
||||
);
|
||||
|
||||
Widget get albumListTile => NavTile(
|
||||
icon: AIcons.album,
|
||||
title: context.l10n.albumPageTitle,
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.rawAlbums.length}'),
|
||||
),
|
||||
routeName: AlbumListPage.routeName,
|
||||
pageBuilder: (_) => const AlbumListPage(),
|
||||
);
|
||||
|
||||
Widget get countryListTile => NavTile(
|
||||
icon: AIcons.location,
|
||||
title: context.l10n.countryPageTitle,
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<CountriesChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.sortedCountries.length}'),
|
||||
),
|
||||
routeName: CountryListPage.routeName,
|
||||
pageBuilder: (_) => const CountryListPage(),
|
||||
);
|
||||
|
||||
Widget get tagListTile => NavTile(
|
||||
icon: AIcons.tag,
|
||||
title: context.l10n.tagPageTitle,
|
||||
trailing: StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, _) => Text('${source.sortedTags.length}'),
|
||||
),
|
||||
routeName: TagListPage.routeName,
|
||||
pageBuilder: (_) => const TagListPage(),
|
||||
);
|
||||
|
||||
Widget get debugTile => NavTile(
|
||||
icon: AIcons.debug,
|
||||
title: 'Debug',
|
||||
Widget get debugTile => PageNavTile(
|
||||
topLevel: false,
|
||||
routeName: AppDebugPage.routeName,
|
||||
pageBuilder: (_) => const AppDebugPage(),
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/drawer/tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CollectionNavTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final String title;
|
||||
final Widget title;
|
||||
final Widget? trailing;
|
||||
final bool dense;
|
||||
final CollectionFilter? filter;
|
||||
|
@ -29,7 +33,7 @@ class CollectionNavTile extends StatelessWidget {
|
|||
bottom: false,
|
||||
child: ListTile(
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
title: title,
|
||||
trailing: trailing,
|
||||
dense: dense,
|
||||
onTap: () => _goToCollection(context),
|
||||
|
@ -54,3 +58,30 @@ class CollectionNavTile extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumNavTile extends StatelessWidget {
|
||||
final String album;
|
||||
|
||||
const AlbumNavTile({
|
||||
Key? key,
|
||||
required this.album,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
var filter = AlbumFilter(album, source.getAlbumDisplayName(context, album));
|
||||
return CollectionNavTile(
|
||||
leading: DrawerFilterIcon(filter: filter),
|
||||
title: DrawerFilterTitle(filter: filter),
|
||||
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||
? const Icon(
|
||||
AIcons.removableStorage,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: null,
|
||||
filter: filter,
|
||||
);
|
||||
}
|
||||
}
|
64
lib/widgets/drawer/page_nav_tile.dart
Normal file
64
lib/widgets/drawer/page_nav_tile.dart
Normal file
|
@ -0,0 +1,64 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/drawer/tile.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PageNavTile extends StatelessWidget {
|
||||
final Widget? trailing;
|
||||
final bool topLevel;
|
||||
final String routeName;
|
||||
final WidgetBuilder? pageBuilder;
|
||||
|
||||
const PageNavTile({
|
||||
Key? key,
|
||||
this.trailing,
|
||||
this.topLevel = true,
|
||||
required this.routeName,
|
||||
required this.pageBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final _pageBuilder = pageBuilder;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
key: Key('$routeName-tile'),
|
||||
leading: DrawerPageIcon(route: routeName),
|
||||
title: DrawerPageTitle(route: routeName),
|
||||
trailing: trailing != null
|
||||
? Builder(
|
||||
builder: (context) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: IconTheme.of(context).color!.withOpacity(.6),
|
||||
),
|
||||
child: trailing!,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: _pageBuilder != null
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
if (routeName != context.currentRouteName) {
|
||||
final route = MaterialPageRoute(
|
||||
settings: RouteSettings(name: routeName),
|
||||
builder: _pageBuilder,
|
||||
);
|
||||
if (topLevel) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
route,
|
||||
(route) => false,
|
||||
);
|
||||
} else {
|
||||
Navigator.push(context, route);
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
selected: context.currentRouteName == routeName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,64 +1,115 @@
|
|||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/theme/themes.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/widgets/debug/app_debug_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NavTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final bool topLevel;
|
||||
final String routeName;
|
||||
final WidgetBuilder pageBuilder;
|
||||
class DrawerFilterIcon extends StatelessWidget {
|
||||
final CollectionFilter? filter;
|
||||
|
||||
const NavTile({
|
||||
const DrawerFilterIcon({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.topLevel = true,
|
||||
required this.routeName,
|
||||
required this.pageBuilder,
|
||||
required this.filter,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: ListTile(
|
||||
key: Key('$title-tile'),
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
trailing: trailing != null
|
||||
? Builder(
|
||||
builder: (context) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: IconTheme.of(context).color!.withOpacity(.6),
|
||||
),
|
||||
child: trailing!,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
if (routeName != context.currentRouteName) {
|
||||
final route = MaterialPageRoute(
|
||||
settings: RouteSettings(name: routeName),
|
||||
builder: pageBuilder,
|
||||
);
|
||||
if (topLevel) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
route,
|
||||
(route) => false,
|
||||
);
|
||||
} else {
|
||||
Navigator.push(context, route);
|
||||
}
|
||||
}
|
||||
},
|
||||
selected: context.currentRouteName == routeName,
|
||||
),
|
||||
);
|
||||
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
|
||||
final iconSize = 24 * textScaleFactor;
|
||||
|
||||
final _filter = filter;
|
||||
if (_filter == null) return Icon(AIcons.allCollection, size: iconSize);
|
||||
return _filter.iconBuilder(context, iconSize) ?? const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
class DrawerFilterTitle extends StatelessWidget {
|
||||
final CollectionFilter? filter;
|
||||
|
||||
const DrawerFilterTitle({
|
||||
Key? key,
|
||||
required this.filter,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String _getString(CollectionFilter? filter) {
|
||||
final l10n = context.l10n;
|
||||
if (filter == null) return l10n.drawerCollectionAll;
|
||||
if (filter == FavouriteFilter.instance) return l10n.drawerCollectionFavourites;
|
||||
if (filter == MimeFilter.image) return l10n.drawerCollectionImages;
|
||||
if (filter == MimeFilter.video) return l10n.drawerCollectionVideos;
|
||||
if (filter == TypeFilter.motionPhoto) return l10n.drawerCollectionMotionPhotos;
|
||||
if (filter == TypeFilter.panorama) return l10n.drawerCollectionPanoramas;
|
||||
if (filter == TypeFilter.sphericalVideo) return l10n.drawerCollectionSphericalVideos;
|
||||
return filter.getLabel(context);
|
||||
}
|
||||
|
||||
return Text(_getString(filter));
|
||||
}
|
||||
}
|
||||
|
||||
class DrawerPageIcon extends StatelessWidget {
|
||||
final String route;
|
||||
|
||||
const DrawerPageIcon({
|
||||
Key? key,
|
||||
required this.route,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (route) {
|
||||
case AlbumListPage.routeName:
|
||||
return const Icon(AIcons.album);
|
||||
case CountryListPage.routeName:
|
||||
return const Icon(AIcons.location);
|
||||
case TagListPage.routeName:
|
||||
return const Icon(AIcons.tag);
|
||||
case AppDebugPage.routeName:
|
||||
return ShaderMask(
|
||||
shaderCallback: Themes.debugGradient.createShader,
|
||||
child: const Icon(AIcons.debug),
|
||||
);
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DrawerPageTitle extends StatelessWidget {
|
||||
final String route;
|
||||
|
||||
const DrawerPageTitle({
|
||||
Key? key,
|
||||
required this.route,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String _getString() {
|
||||
final l10n = context.l10n;
|
||||
switch (route) {
|
||||
case AlbumListPage.routeName:
|
||||
return l10n.albumPageTitle;
|
||||
case CountryListPage.routeName:
|
||||
return l10n.countryPageTitle;
|
||||
case TagListPage.routeName:
|
||||
return l10n.tagPageTitle;
|
||||
case AppDebugPage.routeName:
|
||||
return 'Debug';
|
||||
default:
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
return Text(_getString());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import 'package:aves/model/source/enums.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/basic/query_bar.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
|
@ -29,7 +29,7 @@ class AlbumPickPage extends StatefulWidget {
|
|||
static const routeName = '/album_pick';
|
||||
|
||||
final CollectionSource source;
|
||||
final MoveType moveType;
|
||||
final MoveType? moveType;
|
||||
|
||||
const AlbumPickPage({
|
||||
Key? key,
|
||||
|
@ -92,7 +92,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
|||
|
||||
class AlbumPickAppBar extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final MoveType moveType;
|
||||
final MoveType? moveType;
|
||||
final AlbumChipSetActionDelegate actionDelegate;
|
||||
final ValueNotifier<String> queryNotifier;
|
||||
|
||||
|
@ -117,7 +117,7 @@ class AlbumPickAppBar extends StatelessWidget {
|
|||
case MoveType.move:
|
||||
return context.l10n.albumPickPageTitleMove;
|
||||
default:
|
||||
return moveType.toString();
|
||||
return context.l10n.albumPickPageTitlePick;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,40 +131,43 @@ class AlbumPickAppBar extends StatelessWidget {
|
|||
filterNotifier: queryNotifier,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.createAlbum),
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.pop<String>(context, newAlbum);
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.createAlbumTooltip,
|
||||
),
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
// after the user is done with the popup menu
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (moveType != null)
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.add),
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.pop<String>(context, newAlbum);
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.createAlbumTooltip,
|
||||
),
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<ChipSetAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: context.l10n.menuActionSort, icon: const Icon(AIcons.sort)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.group,
|
||||
child: MenuRow(text: context.l10n.menuActionGroup, icon: const Icon(AIcons.group)),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
// after the user is done with the popup menu
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
|
||||
},
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
floating: true,
|
||||
|
|
|
@ -40,6 +40,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
|||
@override
|
||||
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
|
||||
switch (action) {
|
||||
case ChipSetAction.createAlbum:
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.rename:
|
||||
return true;
|
||||
|
@ -211,12 +212,12 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
|
|||
);
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
|
||||
final destinationAlbumParent = pContext.dirname(album);
|
||||
final destinationAlbum = pContext.join(destinationAlbumParent, newName);
|
||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||
|
||||
if (!(await File(destinationAlbum).exists())) {
|
||||
// access to the destination parent is required to create the underlying destination folder
|
||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbumParent})) return;
|
||||
|
|
|
@ -32,6 +32,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
bool isValid(Set<T> filters, ChipSetAction action) {
|
||||
final hasSelection = filters.isNotEmpty;
|
||||
switch (action) {
|
||||
case ChipSetAction.createAlbum:
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.rename:
|
||||
return false;
|
||||
|
@ -76,10 +77,10 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
_showSortDialog(context);
|
||||
break;
|
||||
case ChipSetAction.map:
|
||||
_goToMap(context);
|
||||
_goToMap(context, filters);
|
||||
break;
|
||||
case ChipSetAction.stats:
|
||||
_goToStats(context);
|
||||
_goToStats(context, filters);
|
||||
break;
|
||||
case ChipSetAction.select:
|
||||
context.read<Selection<FilterGridItem<T>>>().select();
|
||||
|
@ -129,28 +130,33 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
}
|
||||
}
|
||||
|
||||
void _goToMap(BuildContext context) {
|
||||
void _goToMap(BuildContext context, Set<T> filters) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final entries = filters.isEmpty ? source.visibleEntries : source.visibleEntries.where((entry) => filters.any((f) => f.test(entry)));
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
source: source,
|
||||
entries: entries.where((entry) => entry.hasGps).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToStats(BuildContext context) {
|
||||
void _goToStats(BuildContext context, Set<T> filters) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final entries = filters.isEmpty ? source.visibleEntries : source.visibleEntries.where((entry) => filters.any((f) => f.test(entry)));
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: StatsPage.routeName),
|
||||
builder: (context) => StatsPage(
|
||||
source: source,
|
||||
),
|
||||
builder: (context) {
|
||||
return StatsPage(
|
||||
entries: entries.toSet(),
|
||||
source: source,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:aves/model/source/collection_source.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
|
@ -111,7 +111,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
Widget? _buildAppBarTitle(bool isSelecting) {
|
||||
if (isSelecting) {
|
||||
return Selector<Selection<FilterGridItem<T>>, int>(
|
||||
selector: (context, selection) => selection.selection.length,
|
||||
selector: (context, selection) => selection.selectedItems.length,
|
||||
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
|
||||
);
|
||||
} else {
|
||||
|
@ -127,16 +127,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
}
|
||||
|
||||
List<Widget> _buildActions(AppMode appMode, Selection<FilterGridItem<T>> selection) {
|
||||
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
|
||||
final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
|
||||
|
||||
PopupMenuItem<ChipSetAction> toMenuItem(ChipSetAction action, {bool enabled = true}) {
|
||||
return PopupMenuItem(
|
||||
value: action,
|
||||
enabled: enabled && actionDelegate.canApply(selectedFilters, action),
|
||||
child: MenuRow(
|
||||
text: action.getText(context),
|
||||
icon: action.getIcon(),
|
||||
),
|
||||
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -152,13 +149,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
|
||||
final buttonActions = <Widget>[];
|
||||
if (isSelecting) {
|
||||
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
|
||||
final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
|
||||
final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList();
|
||||
buttonActions.addAll(validActions.take(buttonActionCount).map(
|
||||
(action) {
|
||||
final enabled = actionDelegate.canApply(selectedFilters, action);
|
||||
return IconButton(
|
||||
icon: Icon(action.getIcon()),
|
||||
icon: action.getIcon(),
|
||||
onPressed: enabled ? () => applyAction(action) : null,
|
||||
tooltip: action.getText(context),
|
||||
);
|
||||
|
@ -171,51 +168,68 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
|
|||
|
||||
return [
|
||||
...buttonActions,
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final menuItems = <PopupMenuEntry<ChipSetAction>>[
|
||||
toMenuItem(ChipSetAction.sort),
|
||||
if (widget.groupable) toMenuItem(ChipSetAction.group),
|
||||
];
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<ChipSetAction>(
|
||||
key: const Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
final selectedItems = selection.selectedItems;
|
||||
final hasSelection = selectedItems.isNotEmpty;
|
||||
final hasItems = !widget.isEmpty;
|
||||
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
|
||||
|
||||
if (isSelecting) {
|
||||
final selectedItems = selection.selection;
|
||||
final menuItems = <PopupMenuEntry<ChipSetAction>>[
|
||||
toMenuItem(ChipSetAction.sort),
|
||||
if (widget.groupable) toMenuItem(ChipSetAction.group),
|
||||
if (appMode == AppMode.main && !isSelecting)
|
||||
toMenuItem(
|
||||
ChipSetAction.select,
|
||||
enabled: hasItems,
|
||||
),
|
||||
];
|
||||
|
||||
if (selectionRowActions.isNotEmpty) {
|
||||
if (appMode == AppMode.main) {
|
||||
menuItems.add(const PopupMenuDivider());
|
||||
menuItems.addAll(selectionRowActions.map(toMenuItem));
|
||||
if (isSelecting) {
|
||||
menuItems.addAll(selectionRowActions.map(toMenuItem));
|
||||
}
|
||||
menuItems.addAll([
|
||||
toMenuItem(
|
||||
ChipSetAction.map,
|
||||
enabled: otherViewEnabled,
|
||||
),
|
||||
toMenuItem(
|
||||
ChipSetAction.stats,
|
||||
enabled: otherViewEnabled,
|
||||
),
|
||||
]);
|
||||
if (!isSelecting && actionDelegate.isValid(selectedFilters, ChipSetAction.createAlbum)) {
|
||||
menuItems.addAll([
|
||||
const PopupMenuDivider(),
|
||||
toMenuItem(ChipSetAction.createAlbum),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (isSelecting) {
|
||||
menuItems.addAll([
|
||||
const PopupMenuDivider(),
|
||||
toMenuItem(
|
||||
ChipSetAction.selectAll,
|
||||
enabled: selectedItems.length < actionDelegate.allItems.length,
|
||||
),
|
||||
toMenuItem(
|
||||
ChipSetAction.selectNone,
|
||||
enabled: hasSelection,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
menuItems.addAll([
|
||||
const PopupMenuDivider(),
|
||||
toMenuItem(
|
||||
ChipSetAction.selectAll,
|
||||
enabled: selectedItems.length < actionDelegate.allItems.length,
|
||||
),
|
||||
toMenuItem(
|
||||
ChipSetAction.selectNone,
|
||||
enabled: selectedItems.isNotEmpty,
|
||||
),
|
||||
]);
|
||||
} else if (appMode == AppMode.main) {
|
||||
menuItems.addAll([
|
||||
toMenuItem(
|
||||
ChipSetAction.select,
|
||||
enabled: !widget.isEmpty,
|
||||
),
|
||||
toMenuItem(ChipSetAction.map),
|
||||
toMenuItem(ChipSetAction.stats),
|
||||
toMenuItem(ChipSetAction.createAlbum),
|
||||
]);
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
|
||||
},
|
||||
return menuItems;
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -13,8 +13,8 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue