diff --git a/CHANGELOG.md b/CHANGELOG.md index e409b9f58..05119e52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b50198c80..c306ede1a 100644 --- a/README.md +++ b/README.md @@ -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 `/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `/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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 999193448..5f17de9c9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 09725f98b..3cca27c39 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + + + diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 773f2ef19..1c4d47dce 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -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() 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() + // request code to pending runnable + val pendingStorageAccessResultHandlers = ConcurrentHashMap() - 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) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt index df5d58108..24820960d 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -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 { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index ef30b56ab..3b1decf34 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt index 24b81b739..452d35213 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt @@ -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 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index bd9e7967c..af85b3467 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -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("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("mimeType") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 2a32f93ba..aae2b6da4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index 8e9ae3001..8fc0b0f9c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -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) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index cd7e590a4..58f2729cf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -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() }, { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index e546681c8..5af94b640 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -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) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 64b605da8..c0426c55c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -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 } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index cfb0bb307..96da1ae62 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -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") diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 0692d7deb..c4c3ba8db 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -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) { diff --git a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_collection_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_collection_foreground.xml deleted file mode 100644 index 45b1c8cb2..000000000 --- a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_collection_foreground.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_movie_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_movie_foreground.xml deleted file mode 100644 index 0955cd1d5..000000000 --- a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_movie_foreground.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_search_foreground.xml b/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_search_foreground.xml deleted file mode 100644 index fb2972b41..000000000 --- a/android/app/src/main/res/drawable-anydpi-v26/ic_shortcut_search_foreground.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable-v21/ic_shortcut_collection.xml b/android/app/src/main/res/drawable-v21/ic_shortcut_collection.xml new file mode 100644 index 000000000..f20e57d59 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/ic_shortcut_collection.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-v21/ic_shortcut_movie.xml b/android/app/src/main/res/drawable-v21/ic_shortcut_movie.xml new file mode 100644 index 000000000..c69c3792a --- /dev/null +++ b/android/app/src/main/res/drawable-v21/ic_shortcut_movie.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-v21/ic_shortcut_search.xml b/android/app/src/main/res/drawable-v21/ic_shortcut_search.xml new file mode 100644 index 000000000..f28c5497f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/ic_shortcut_search.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-v26/ic_shortcut_collection_foreground.xml b/android/app/src/main/res/drawable-v26/ic_shortcut_collection_foreground.xml new file mode 100644 index 000000000..13e09005a --- /dev/null +++ b/android/app/src/main/res/drawable-v26/ic_shortcut_collection_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/drawable-v26/ic_shortcut_movie_foreground.xml b/android/app/src/main/res/drawable-v26/ic_shortcut_movie_foreground.xml new file mode 100644 index 000000000..84db2f541 --- /dev/null +++ b/android/app/src/main/res/drawable-v26/ic_shortcut_movie_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/drawable-v26/ic_shortcut_search_foreground.xml b/android/app/src/main/res/drawable-v26/ic_shortcut_search_foreground.xml new file mode 100644 index 000000000..13f707135 --- /dev/null +++ b/android/app/src/main/res/drawable-v26/ic_shortcut_search_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml index e069568b3..8ec076f9f 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_collection.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml index a31978c2d..8789ba05f 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_movie.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml index 1ab11ea64..ab1fb94ae 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_shortcut_search.xml @@ -1,5 +1,5 @@ - - + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 9a0f0bc25..6e3fbd50f 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -2,5 +2,6 @@ #FFFFFF #FFFFFF + #455A64 #3f51b5 \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 37844e272..ab428ae27 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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' } } diff --git a/lib/image_providers/app_icon_image_provider.dart b/lib/image_providers/app_icon_image_provider.dart index 91aecf1ac..3628e7481 100644 --- a/lib/image_providers/app_icon_image_provider.dart +++ b/lib/image_providers/app_icon_image_provider.dart @@ -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 { const AppIconImage({ @@ -39,10 +40,7 @@ class AppIconImage extends ImageProvider { Future _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'); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9c666f5a5..1cd437d31 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3622d2ec7..0002cb28a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -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": "저장공간 접근", diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index afc7e6721..046850c81 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -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; diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 5fdbc2e3d..83a43d636 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -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; diff --git a/lib/model/actions/collection_actions.dart b/lib/model/actions/collection_actions.dart deleted file mode 100644 index 45aa9b110..000000000 --- a/lib/model/actions/collection_actions.dart +++ /dev/null @@ -1,14 +0,0 @@ -enum CollectionAction { - addShortcut, - sort, - group, - select, - selectAll, - selectNone, - map, - stats, - // apply to entry set - copy, - move, - refreshMetadata, -} diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index f54eecf70..ceb1b528e 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -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 diff --git a/lib/model/actions/entry_set_actions.dart b/lib/model/actions/entry_set_actions.dart new file mode 100644 index 000000000..aa8cb1810 --- /dev/null +++ b/lib/model/actions/entry_set_actions.dart @@ -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; + } + } +} diff --git a/lib/model/actions/video_actions.dart b/lib/model/actions/video_actions.dart index d4de71750..dcddaf60b 100644 --- a/lib/model/actions/video_actions.dart +++ b/lib/model/actions/video_actions.dart @@ -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; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 50a3f8a28..9c036e9a6 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -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 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: diff --git a/lib/model/selection.dart b/lib/model/selection.dart index 6f85cdcae..2d19227ed 100644 --- a/lib/model/selection.dart +++ b/lib/model/selection.dart @@ -5,41 +5,45 @@ class Selection extends ChangeNotifier { bool get isSelecting => _isSelecting; - final Set _selection = {}; + final Set _selectedItems = {}; - Set get selection => _selection; + Set get selectedItems => _selectedItems; void browse() { + if (!_isSelecting) return; clearSelection(); _isSelecting = false; notifyListeners(); } void select() { + if (_isSelecting) return; _isSelecting = true; notifyListeners(); } - bool isSelected(Iterable items) => items.every(selection.contains); + bool isSelected(Iterable items) => items.every(selectedItems.contains); void addToSelection(Iterable items) { - _selection.addAll(items); + if (items.isEmpty) return; + _selectedItems.addAll(items); notifyListeners(); } void removeFromSelection(Iterable 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(); } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 229d292b5..e2be8896c 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -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 get drawerTypeBookmarks => + (_prefs!.getStringList(drawerTypeBookmarksKey))?.map((v) { + if (v.isEmpty) return null; + return CollectionFilter.fromJson(v); + }).toList() ?? + drawerTypeBookmarksDefault; + + set drawerTypeBookmarks(List newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList()); + + List? get drawerAlbumBookmarks => _prefs!.getStringList(drawerAlbumBookmarksKey); + + set drawerAlbumBookmarks(List? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue); + + List get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? drawerPageBookmarksDefault; + + set drawerPageBookmarks(List 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: diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 55f43af2f..0770f2494 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -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; } } diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index f767408c5..f91df8aeb 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -159,6 +159,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future renameAlbum(String sourceAlbum, String destinationAlbum, Set todoEntries, Set 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); } diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index c593865d0..302e3ddcf 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -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 alphaImages = [bmp, gif, ico, png, svg, tiff, webp]; + static const Set alphaImages = {bmp, gif, ico, png, svg, tiff, webp}; - static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; + static const Set 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 undecodableImages = {art, crw, djvu, psdVnd, psdX}; + + static const Set _knownOpaqueImages = {heic, heif, jpeg}; + + static const Set _knownVideos = {avi, mkv, mov, mp2t, mp4, ogg}; + + static final Set knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; static bool isImage(String mimeType) => mimeType.startsWith('image'); diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index d8b8dbf72..09314686b 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -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; } diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index ae063ac12..d106669a3 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -9,40 +9,40 @@ class AndroidDebugService { static Future 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 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 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 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 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 {}; } diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 90fb1a102..9d96312b5 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -13,9 +13,7 @@ class AppShortcutService { static bool? _canPin; static Future 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); } } } diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 82ba698f6..725d8577d 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -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; } diff --git a/lib/services/embedded_data_service.dart b/lib/services/embedded_data_service.dart index 4a2276dfb..0f2b3af82 100644 --- a/lib/services/embedded_data_service.dart +++ b/lib/services/embedded_data_service.dart @@ -26,8 +26,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { 'sizeBytes': entry.sizeBytes, }); if (result != null) return (result as List).cast(); - } 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 {}; } diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index e993e64a5..2e41263fe 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -20,8 +20,8 @@ class GeocodingService { 'maxResults': 2, }); return (result as List).cast().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 []; } diff --git a/lib/services/global_search.dart b/lib/services/global_search.dart index 055af0e5c..c4a785196 100644 --- a/lib/services/global_search.dart +++ b/lib/services/global_search.dart @@ -14,8 +14,8 @@ class GlobalSearch { await platform.invokeMethod('registerCallback', { 'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(), }); - } on PlatformException catch (e) { - await reportService.recordChannelError('registerCallback', e); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); } } } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index 870f42468..c7ebb2c79 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -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 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(); - } 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(); - } 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(); - } 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(); - } on PlatformException catch (e) { - await reportService.recordChannelError('flip', e); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); } return {}; } diff --git a/lib/services/media_store_service.dart b/lib/services/media_store_service.dart index 32f3acf4f..3363f1686 100644 --- a/lib/services/media_store_service.dart +++ b/lib/services/media_store_service.dart @@ -25,8 +25,8 @@ class PlatformMediaStoreService implements MediaStoreService { 'knownContentIds': knownContentIds, }); return (result as List).cast(); - } 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(); - } 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({ '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); } } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 258e34676..d6ab7c509 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -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 getPanoramaInfo(AvesEntry entry); + Future hasContentResolverProp(String prop); + Future 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 _contentResolverProps = {}; + + @override + Future hasContentResolverProp(String prop) async { + var exists = _contentResolverProps[prop]; + if (exists != null) return SynchronousFuture(exists); + + try { + exists = await platform.invokeMethod('hasContentResolverProp', { + 'prop': prop, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + exists ??= false; + _contentResolverProps[prop] = exists; + return exists; + } + @override Future 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; } diff --git a/lib/services/report_service.dart b/lib/services/report_service.dart index feab952a2..00930ee28 100644 --- a/lib/services/report_service.dart +++ b/lib/services/report_service.dart @@ -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 recordError(dynamic exception, StackTrace? stack); Future recordFlutterError(FlutterErrorDetails flutterErrorDetails); - - Future recordChannelError(String method, PlatformException e) { - return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null); - } } class CrashlyticsReportService extends ReportService { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 53d460f5f..cba8baadf 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -47,8 +47,8 @@ class PlatformStorageService implements StorageService { try { final result = await platform.invokeMethod('getStorageVolumes'); return (result as List).cast().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(); - } 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', { '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(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(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; } diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index bc0ea606c..7f7cf2a4f 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -12,8 +12,8 @@ class PlatformTimeService implements TimeService { Future 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; } diff --git a/lib/services/viewer_service.dart b/lib/services/viewer_service.dart index 0a3bfa089..ec76a6bcc 100644 --- a/lib/services/viewer_service.dart +++ b/lib/services/viewer_service.dart @@ -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(); - } 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', { 'uri': uri, }); - } on PlatformException catch (e) { - await reportService.recordChannelError('pick', e); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); } } } diff --git a/lib/services/window_service.dart b/lib/services/window_service.dart index cf204de4b..b325d264b 100644 --- a/lib/services/window_service.dart +++ b/lib/services/window_service.dart @@ -23,8 +23,8 @@ class PlatformWindowService implements WindowService { await platform.invokeMethod('keepScreenOn', { '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', { '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', { 'use': use, }); - } on PlatformException catch (e) { - await reportService.recordChannelError('setCutoutMode', e); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); } } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 2edc3b3bf..29ae6881c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -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); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 656334fe7..05f4c5d43 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -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; diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index df131dcdb..73f4c2ad4 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -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, diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index fbc713a6d..d51b67304 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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 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', diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 6a142abe3..17f005d94 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -123,17 +123,19 @@ class _AvesAppState extends State { } Future _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(); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 22d2f2f00..0f364a23c 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -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 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 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 with SingleTickerPr Widget? _buildAppBarTitle(bool isSelecting) { if (isSelecting) { return Selector, 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 with SingleTickerPr ), if (isSelecting) ...EntryActions.selection.map((action) => Selector, 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 with SingleTickerPr future: _canAddShortcutsLoader, builder: (context, snapshot) { final canAddShortcuts = snapshot.data ?? false; - return PopupMenuButton( - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - final selection = context.read>(); - 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( + key: const Key('appbar-menu-button'), + itemBuilder: (context) { + final groupable = collection.sortFactor == EntrySortFactor.date; + final selection = context.read>(); + 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 _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>().isSelecting) { _browseToSelectAnimation.forward(); @@ -278,36 +271,41 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - void _updateHeight() { + void _onFilterChanged() { widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0); + + if (hasFilters) { + final filters = collection.filters; + final selection = context.read>(); + if (selection.isSelecting) { + final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet(); + selection.removeFromSelection(toRemove); + } + } } - Future _onCollectionActionSelected(CollectionAction action) async { + Future _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>().select(); break; - case CollectionAction.selectAll: + case EntrySetAction.selectAll: context.read>().addToSelection(collection.sortedEntries); break; - case CollectionAction.selectNone: + case EntrySetAction.selectNone: context.read>().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( context: context, builder: (context) => AvesSelectionDialog( @@ -327,7 +325,7 @@ class _CollectionAppBarState extends State with SingleTickerPr settings.collectionSectionFactor = value; } break; - case CollectionAction.sort: + case EntrySetAction.sort: final value = await showDialog( context: context, builder: (context) => AvesSelectionDialog( @@ -385,30 +383,4 @@ class _CollectionAppBarState extends State 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, - ), - ), - ); - } } diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 8a66526f6..ef44fe919 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -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'; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index de3a99bb1..4a09f2684 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.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 _getExpandedSelectedItems(Selection 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>(); + final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read().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>(); + final collection = context.read(); + 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, + ), + ), + ); + } } diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 28b24b3f9..4e2bb857f 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -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'; diff --git a/lib/widgets/common/basic/menu.dart b/lib/widgets/common/basic/menu.dart new file mode 100644 index 000000000..3fe9f5583 --- /dev/null +++ b/lib/widgets/common/basic/menu.dart @@ -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, + ); + } +} diff --git a/lib/widgets/common/basic/menu_row.dart b/lib/widgets/common/basic/menu_row.dart deleted file mode 100644 index 81492121a..000000000 --- a/lib/widgets/common/basic/menu_row.dart +++ /dev/null @@ -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)), - ], - ); - } -} diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index d43fa431a..37ec5aa5d 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -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(), ); diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index b05dcaf87..a4192241a 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -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(); + final child = OverlayIcon( icon: icon, - size: context.select((t) => t.iconSize), + size: gridTheme.iconSize, iconScale: .8, text: text, ); + return DefaultTextStyle( + style: TextStyle( + color: Colors.grey.shade200, + fontSize: gridTheme.fontSize, + ), + child: child, + ); } } diff --git a/lib/widgets/common/map/buttons.dart b/lib/widgets/common/map/buttons.dart index febc07549..5dae98abf 100644 --- a/lib/widgets/common/map/buttons.dart +++ b/lib/widgets/common/map/buttons.dart @@ -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 boundsNotifier; final Future 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( - context: context, - builder: (context) { - return AvesSelectionDialog( - 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( + 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( + context: context, + builder: (context) { + return AvesSelectionDialog( + 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, ), diff --git a/lib/widgets/common/map/compass.dart b/lib/widgets/common/map/compass.dart new file mode 100644 index 000000000..0e8cc4f70 --- /dev/null +++ b/lib/widgets/common/map/compass.dart @@ -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; +} diff --git a/lib/widgets/common/map/controller.dart b/lib/widgets/common/map/controller.dart new file mode 100644 index 000000000..584f6ea7d --- /dev/null +++ b/lib/widgets/common/map/controller.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:latlong2/latlong.dart'; + +class AvesMapController { + final StreamController _streamController = StreamController.broadcast(); + + Stream get _events => _streamController.stream; + + Stream get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast(); + + void dispose() { + _streamController.close(); + } + + void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng)); +} + +class MapControllerMoveEvent { + final LatLng latLng; + + MapControllerMoveEvent(this.latLng); +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index ba2c26270..929c5f2d2 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -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 entries; final bool interactive; final double? mapHeight; final ValueNotifier 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 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 boundsNotifier; + late final ValueNotifier _boundsNotifier; + late final Fluster _defaultMarkerCluster; + Fluster? _slowMarkerCluster; List get entries => widget.entries; @@ -58,36 +70,49 @@ class _GeoMapState extends State 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( - // 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 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( future: availability.isConnected, @@ -98,7 +123,7 @@ class _GeoMapState extends State 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 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 with TickerProviderStateMixin { interactive: interactive, ), MapButtonPanel( - latLng: boundsNotifier.value.center, + boundsNotifier: _boundsNotifier, ), ], ); @@ -185,6 +216,46 @@ class _GeoMapState extends State with TickerProviderStateMixin { }, ); } + + Fluster _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( + // 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 _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 Function(); +typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); +typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set Function() getClusterEntries); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 922c7c458..6a8a310f6 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -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 boundsNotifier; final bool interactive; + final double? minZoom, maxZoom; final EntryMapStyle style; - final EntryMarkerBuilder markerBuilder; - final Fluster markerCluster; - final List 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 with WidgetsBindingObserver { - GoogleMapController? _controller; + GoogleMapController? _googleMapController; + final List _subscriptions = []; + Map _geoEntryByMarkerKey = {}; final Map _markerBitmaps = {}; final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); @@ -50,28 +55,45 @@ class _EntryGoogleMapState extends State 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 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( - valueListenable: boundsNotifier, - builder: (context, visibleRegion, child) { - final allEntries = widget.markerEntries; - final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - 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( - 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( + 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 clusterByMarkerKey) { + Widget _buildMap() { return AnimatedBuilder( animation: _markerBitmapChangeNotifier, builder: (context, child) { final markers = {}; - 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 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 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 _updateVisibleRegion(double zoom) async { - final bounds = await _controller?.getVisibleRegion(); + void _updateClusters() { + if (!mounted) return; + setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); + } + + Future _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 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 _resetRotation() async { + final controller = _googleMapController; + if (controller == null) return; + + await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( + target: _toGoogleLatLng(bounds.center), + zoom: bounds.zoom, + ))); + } + Future _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 _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); diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 51ad8e06f..508ffd707 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -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 boundsNotifier; final bool interactive; + final double minZoom, maxZoom; final EntryMapStyle style; - final EntryMarkerBuilder markerBuilder; - final Fluster markerCluster; - final List 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 with TickerProviderStateMixin { - final MapController _mapController = MapController(); + final MapController _leafletMapController = MapController(); final List _subscriptions = []; + Map _geoEntryByMarkerKey = {}; + final Debouncer _debouncer = Debouncer(delay: Durations.mapIdleDebounceDelay); ValueNotifier 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( - valueListenable: boundsNotifier, - builder: (context, visibleRegion, child) { - final allEntries = widget.markerEntries; - final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - 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 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 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 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 _resetRotation() async { + final rotationTween = Tween(begin: _leafletMapController.rotation, end: 0); + await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation))); + } + Future _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(begin: _mapController.zoom, end: endZoom); - await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation))); + final zoomTween = Tween(begin: _leafletMapController.zoom, end: endZoom); + await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation))); } Future _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 _animateCamera(void Function(Animation animation) animate) async { diff --git a/lib/widgets/common/map/leaflet/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart index 4a97aa3c1..d87005bcc 100644 --- a/lib/widgets/common/map/leaflet/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -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; diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index c8e781c80..4b8abf894 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -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'; diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart index a19f42e5f..2faca0e46 100644 --- a/lib/widgets/common/map/zoomed_bounds.dart +++ b/lib/widgets/common/map/zoomed_bounds.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 get boundingBox => [west, south, east, north]; LatLng get center => LatLng((north + south) / 2, (east + west) / 2); @override - List get props => [west, south, east, north, zoom]; + List 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, ); } } diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart similarity index 94% rename from lib/widgets/collection/thumbnail/decorated.dart rename to lib/widgets/common/thumbnail/decorated.dart index af7da2574..93f16b6d0 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -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 { diff --git a/lib/widgets/collection/thumbnail/error.dart b/lib/widgets/common/thumbnail/error.dart similarity index 100% rename from lib/widgets/collection/thumbnail/error.dart rename to lib/widgets/common/thumbnail/error.dart diff --git a/lib/widgets/collection/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart similarity index 99% rename from lib/widgets/collection/thumbnail/image.dart rename to lib/widgets/common/thumbnail/image.dart index 20965d397..15f5c16c8 100644 --- a/lib/widgets/collection/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -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'; diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart similarity index 100% rename from lib/widgets/collection/thumbnail/overlay.dart rename to lib/widgets/common/thumbnail/overlay.dart diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart new file mode 100644 index 000000000..dc13d0698 --- /dev/null +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -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 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 { + 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 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( + 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 _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(); +} diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index e61a7aa1b..c54138a13 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -15,7 +15,7 @@ class DebugSettingsSection extends StatelessWidget { Widget build(BuildContext context) { return Consumer( 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), diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 95385a67f..213b6ee24 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -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'; diff --git a/lib/widgets/dialogs/cover_selection_dialog.dart b/lib/widgets/dialogs/cover_selection_dialog.dart index 54aa452be..4177ee42f 100644 --- a/lib/widgets/dialogs/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/cover_selection_dialog.dart @@ -77,9 +77,9 @@ class _CoverSelectionDialogState extends State { title, const Spacer(), IconButton( - onPressed: _isCustom ? _pickEntry : null, - tooltip: 'Change', icon: const Icon(AIcons.setCover), + onPressed: _isCustom ? _pickEntry : null, + tooltip: context.l10n.changeTooltip, ), ]) : title, diff --git a/lib/widgets/drawer/album_tile.dart b/lib/widgets/drawer/album_tile.dart deleted file mode 100644 index bcb41c34a..000000000 --- a/lib/widgets/drawer/album_tile.dart +++ /dev/null @@ -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(); - 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), - ); - } -} diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 65cb80d1b..bdee8ebfc 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -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 getDefaultAlbums(BuildContext context) { + final source = context.read(); + 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 { @@ -47,19 +55,11 @@ class _AppDrawerState extends State { @override Widget build(BuildContext context) { - final hiddenFilters = settings.hiddenFilters; - final showVideos = !hiddenFilters.contains(MimeFilter.video); - final showFavourites = !hiddenFilters.contains(FavouriteFilter.instance); final drawerItems = [ _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 { ); } - Widget _buildSpecialAlbumSection() { + List _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(), 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 _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(), + builder: (context, _) => Text('${source.rawAlbums.length}'), + ); + break; + case CountryListPage.routeName: + pageBuilder = (_) => const CountryListPage(); + trailing = StreamBuilder( + stream: source.eventBus.on(), + builder: (context, _) => Text('${source.sortedCountries.length}'), + ); + break; + case TagListPage.routeName: + pageBuilder = (_) => const TagListPage(); + trailing = StreamBuilder( + stream: source.eventBus.on(), + 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(), - 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(), - 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(), - 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(), diff --git a/lib/widgets/drawer/collection_tile.dart b/lib/widgets/drawer/collection_nav_tile.dart similarity index 59% rename from lib/widgets/drawer/collection_tile.dart rename to lib/widgets/drawer/collection_nav_tile.dart index 761c9c529..7368f6483 100644 --- a/lib/widgets/drawer/collection_tile.dart +++ b/lib/widgets/drawer/collection_nav_tile.dart @@ -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(); + 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, + ); + } +} diff --git a/lib/widgets/drawer/page_nav_tile.dart b/lib/widgets/drawer/page_nav_tile.dart new file mode 100644 index 000000000..fdf462655 --- /dev/null +++ b/lib/widgets/drawer/page_nav_tile.dart @@ -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, + ), + ); + } +} diff --git a/lib/widgets/drawer/tile.dart b/lib/widgets/drawer/tile.dart index 3b250e79d..575aa6483 100644 --- a/lib/widgets/drawer/tile.dart +++ b/lib/widgets/drawer/tile.dart @@ -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()); } } diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 714cadc13..0c8cbd395 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -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 { class AlbumPickAppBar extends StatelessWidget { final CollectionSource source; - final MoveType moveType; + final MoveType? moveType; final AlbumChipSetActionDelegate actionDelegate; final ValueNotifier 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( - context: context, - builder: (context) => const CreateAlbumDialog(), - ); - if (newAlbum != null && newAlbum.isNotEmpty) { - Navigator.pop(context, newAlbum); - } - }, - tooltip: context.l10n.createAlbumTooltip, - ), - PopupMenuButton( - 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( + context: context, + builder: (context) => const CreateAlbumDialog(), + ); + if (newAlbum != null && newAlbum.isNotEmpty) { + Navigator.pop(context, newAlbum); + } + }, + tooltip: context.l10n.createAlbumTooltip, + ), + MenuIconTheme( + child: PopupMenuButton( + 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, diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index f14b9d5a9..ffef65bd4 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -40,6 +40,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { @override bool isValid(Set filters, ChipSetAction action) { switch (action) { + case ChipSetAction.createAlbum: case ChipSetAction.delete: case ChipSetAction.rename: return true; @@ -211,12 +212,12 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { ); 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; diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 1ff248eb8..6612f056d 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -32,6 +32,7 @@ abstract class ChipSetActionDelegate with FeedbackMi bool isValid(Set 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 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>>().select(); @@ -129,28 +130,33 @@ abstract class ChipSetActionDelegate with FeedbackMi } } - void _goToMap(BuildContext context) { + void _goToMap(BuildContext context, Set filters) { final source = context.read(); + 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 filters) { final source = context.read(); + 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, + ); + }, ), ); } diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 53d188ebd..8c48c9433 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -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 extends State>, 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 extends State _buildActions(AppMode appMode, Selection> selection) { - final selectedFilters = selection.selection.map((v) => v.filter).toSet(); + final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet(); PopupMenuItem 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 extends State[]; 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 extends State( - key: const Key('appbar-menu-button'), - itemBuilder: (context) { - final menuItems = >[ - toMenuItem(ChipSetAction.sort), - if (widget.groupable) toMenuItem(ChipSetAction.group), - ]; + MenuIconTheme( + child: PopupMenuButton( + 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 = >[ + 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)); + }, + ), ), ]; } diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 693476e09..7181bd4d3 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -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'; diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 3e424ac83..3dfb0c6d9 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -1,37 +1,39 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; class MapPage extends StatefulWidget { static const routeName = '/collection/map'; - final CollectionSource source; - final CollectionLens? parentCollection; - late final List entries; + final List entries; - MapPage({ + const MapPage({ Key? key, - required this.source, - this.parentCollection, - }) : super(key: key) { - entries = (parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries).where((entry) => entry.hasGps).toList(); - } + required this.entries, + }) : super(key: key); @override _MapPageState createState() => _MapPageState(); } class _MapPageState extends State { + final AvesMapController _mapController = AvesMapController(); late final ValueNotifier _isAnimatingNotifier; + final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); + final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay); + + List get entries => widget.entries; @override void initState() { @@ -45,6 +47,14 @@ class _MapPageState extends State { } else { _isAnimatingNotifier = ValueNotifier(false); } + _selectedIndexNotifier.addListener(_onThumbnailIndexChange); + } + + @override + void dispose() { + _mapController.dispose(); + _selectedIndexNotifier.removeListener(_onThumbnailIndexChange); + super.dispose(); } @override @@ -55,13 +65,45 @@ class _MapPageState extends State { title: Text(context.l10n.mapPageTitle), ), body: SafeArea( - child: GeoMap( - entries: widget.entries, - interactive: true, - isAnimatingNotifier: _isAnimatingNotifier, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: GeoMap( + controller: _mapController, + entries: entries, + interactive: true, + isAnimatingNotifier: _isAnimatingNotifier, + onMarkerTap: (markerEntry, getClusterEntries) { + final index = entries.indexOf(markerEntry); + if (_selectedIndexNotifier.value != index) { + _selectedIndexNotifier.value = index; + } else { + _moveToEntry(markerEntry); + } + }, + ), + ), + const Divider(), + Selector( + selector: (c, mq) => mq.size.width, + builder: (c, mqWidth, child) { + return ThumbnailScroller( + availableWidth: mqWidth, + entryCount: entries.length, + entryBuilder: (index) => entries[index], + indexNotifier: _selectedIndexNotifier, + ); + }, + ), + ], ), ), ), ); } + + void _onThumbnailIndexChange() => _moveToEntry(widget.entries[_selectedIndexNotifier.value]); + + void _moveToEntry(AvesEntry entry) => _debouncer(() => _mapController.moveTo(entry.latLng!)); } diff --git a/lib/widgets/settings/common/quick_actions/action_button.dart b/lib/widgets/settings/common/quick_actions/action_button.dart index 9e8c7f647..a941a1951 100644 --- a/lib/widgets/settings/common/quick_actions/action_button.dart +++ b/lib/widgets/settings/common/quick_actions/action_button.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; class ActionButton extends StatelessWidget { final String text; - final IconData? icon; + final Widget? icon; final bool enabled, showCaption; const ActionButton({ @@ -27,7 +27,7 @@ class ActionButton extends StatelessWidget { const SizedBox(height: padding), OverlayButton( child: IconButton( - icon: Icon(icon), + icon: icon ?? const SizedBox(), onPressed: enabled ? () {} : null, ), ), diff --git a/lib/widgets/settings/common/quick_actions/available_actions.dart b/lib/widgets/settings/common/quick_actions/available_actions.dart index be1044939..2f1a9ed7d 100644 --- a/lib/widgets/settings/common/quick_actions/available_actions.dart +++ b/lib/widgets/settings/common/quick_actions/available_actions.dart @@ -10,7 +10,7 @@ class AvailableActionPanel extends StatelessWidget { final ValueNotifier draggedQuickAction; final ValueNotifier draggedAvailableAction; final bool Function(T? action) removeQuickAction; - final IconData? Function(T action) actionIcon; + final Widget? Function(T action) actionIcon; final String Function(BuildContext context, T action) actionText; const AvailableActionPanel({ diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index 37058f902..eb3662b3c 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -18,7 +18,7 @@ import 'package:flutter/material.dart'; class QuickActionEditorPage extends StatefulWidget { final String title, bannerText; final List allAvailableActions; - final IconData? Function(T action) actionIcon; + final Widget? Function(T action) actionIcon; final String Function(BuildContext context, T action) actionText; final List Function() load; final void Function(List actions) save; @@ -287,7 +287,7 @@ class _QuickActionEditorPageState extends State const NavigationDrawerEditorPage(), + ), + ); + }, + ); + } +} + +class NavigationDrawerEditorPage extends StatefulWidget { + static const routeName = '/settings/navigation_drawer'; + + const NavigationDrawerEditorPage({Key? key}) : super(key: key); + + @override + _NavigationDrawerEditorPageState createState() => _NavigationDrawerEditorPageState(); +} + +class _NavigationDrawerEditorPageState extends State { + final List _typeItems = []; + final Set _visibleTypes = {}; + final List _albumItems = []; + final List _pageItems = []; + final Set _visiblePages = {}; + + static final Set _typeOptions = { + null, + ...CollectionSearchDelegate.typeFilters, + }; + static const Set _pageOptions = { + AlbumListPage.routeName, + CountryListPage.routeName, + TagListPage.routeName, + }; + + @override + void initState() { + super.initState(); + final userTypeLinks = settings.drawerTypeBookmarks; + _visibleTypes.addAll(userTypeLinks); + _typeItems.addAll(userTypeLinks); + _typeItems.addAll(_typeOptions.where((v) => !userTypeLinks.contains(v))); + + _albumItems.addAll(settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context)); + + final userPageLinks = settings.drawerPageBookmarks; + _visiblePages.addAll(userPageLinks); + _pageItems.addAll(userPageLinks); + _pageItems.addAll(_pageOptions.where((v) => !userPageLinks.contains(v))); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final tabs = >[ + Tuple2( + Tab(text: l10n.settingsNavigationDrawerTabTypes), + DrawerFixedListTab( + items: _typeItems, + visibleItems: _visibleTypes, + leading: (item) => DrawerFilterIcon(filter: item), + title: (item) => DrawerFilterTitle(filter: item), + ), + ), + Tuple2( + Tab(text: l10n.settingsNavigationDrawerTabAlbums), + DrawerAlbumTab( + items: _albumItems, + ), + ), + Tuple2( + Tab(text: l10n.settingsNavigationDrawerTabPages), + DrawerFixedListTab( + items: _pageItems, + visibleItems: _visiblePages, + leading: (item) => DrawerPageIcon(route: item), + title: (item) => DrawerPageTitle(route: item), + ), + ), + ]; + + return DefaultTabController( + length: tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(l10n.settingsNavigationDrawerEditorTitle), + bottom: TabBar( + tabs: tabs.map((t) => t.item1).toList(), + ), + ), + body: WillPopScope( + onWillPop: () { + settings.drawerTypeBookmarks = _typeItems.where(_visibleTypes.contains).toList(); + settings.drawerAlbumBookmarks = _albumItems; + settings.drawerPageBookmarks = _pageItems.where(_visiblePages.contains).toList(); + return SynchronousFuture(true); + }, + child: SafeArea( + child: TabBarView( + children: tabs.map((t) => t.item2).toList(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/navigation/drawer_editor_banner.dart b/lib/widgets/settings/navigation/drawer_editor_banner.dart new file mode 100644 index 000000000..15b9a626e --- /dev/null +++ b/lib/widgets/settings/navigation/drawer_editor_banner.dart @@ -0,0 +1,22 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class DrawerEditorBanner extends StatelessWidget { + const DrawerEditorBanner({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(AIcons.info), + const SizedBox(width: 16), + Expanded(child: Text(context.l10n.settingsNavigationDrawerBanner)), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart new file mode 100644 index 000000000..4ee643695 --- /dev/null +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -0,0 +1,89 @@ +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/drawer/tile.dart'; +import 'package:aves/widgets/filter_grids/album_pick.dart'; +import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DrawerAlbumTab extends StatefulWidget { + final List items; + + const DrawerAlbumTab({ + Key? key, + required this.items, + }) : super(key: key); + + @override + _DrawerAlbumTabState createState() => _DrawerAlbumTabState(); +} + +class _DrawerAlbumTabState extends State { + @override + Widget build(BuildContext context) { + final source = context.read(); + return Column( + children: [ + const DrawerEditorBanner(), + const Divider(height: 0), + Flexible( + child: ReorderableListView.builder( + itemBuilder: (context, index) { + final album = widget.items[index]; + final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); + return ListTile( + key: ValueKey(album), + leading: DrawerFilterIcon(filter: filter), + title: DrawerFilterTitle(filter: filter), + trailing: IconButton( + icon: const Icon(AIcons.clear), + onPressed: () { + setState(() => widget.items.remove(album)); + }, + tooltip: context.l10n.removeTooltip, + ), + ); + }, + itemCount: widget.items.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (oldIndex < newIndex) newIndex -= 1; + widget.items.insert(newIndex, widget.items.removeAt(oldIndex)); + }); + }, + shrinkWrap: true, + ), + ), + const Divider(height: 0), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () async { + final source = context.read(); + final album = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: AlbumPickPage.routeName), + builder: (context) => AlbumPickPage(source: source, moveType: null), + ), + ); + + if (album == null || album.isEmpty) return; + + setState(() { + widget.items.add(album); + }); + }, + style: ButtonStyle( + side: MaterialStateProperty.all(BorderSide(color: Theme.of(context).accentColor)), + foregroundColor: MaterialStateProperty.all(Colors.white), + ), + icon: const Icon(AIcons.add), + label: Text(context.l10n.settingsNavigationDrawerAddAlbum), + ) + ], + ); + } +} diff --git a/lib/widgets/settings/navigation/drawer_tab_fixed.dart b/lib/widgets/settings/navigation/drawer_tab_fixed.dart new file mode 100644 index 000000000..78f965836 --- /dev/null +++ b/lib/widgets/settings/navigation/drawer_tab_fixed.dart @@ -0,0 +1,75 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +typedef ItemWidgetBuilder = Widget Function(T item); + +class DrawerFixedListTab extends StatefulWidget { + final List items; + final Set visibleItems; + final ItemWidgetBuilder leading; + final ItemWidgetBuilder title; + + const DrawerFixedListTab({ + Key? key, + required this.items, + required this.visibleItems, + required this.leading, + required this.title, + }) : super(key: key); + + @override + _DrawerFixedListTabState createState() => _DrawerFixedListTabState(); +} + +class _DrawerFixedListTabState extends State> { + Set get visibleItems => widget.visibleItems; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const DrawerEditorBanner(), + const Divider(height: 0), + Flexible( + child: ReorderableListView.builder( + itemBuilder: (context, index) { + final filter = widget.items[index]; + final visible = visibleItems.contains(filter); + return Opacity( + key: ValueKey(filter), + opacity: visible ? 1 : .4, + child: ListTile( + leading: widget.leading(filter), + title: widget.title(filter), + trailing: IconButton( + icon: Icon(visible ? AIcons.hide : AIcons.show), + onPressed: () { + setState(() { + if (visible) { + visibleItems.remove(filter); + } else { + visibleItems.add(filter); + } + }); + }, + tooltip: visible ? context.l10n.hideTooltip : context.l10n.showTooltip, + ), + ), + ); + }, + itemCount: widget.items.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (oldIndex < newIndex) newIndex -= 1; + widget.items.insert(newIndex, widget.items.removeAt(oldIndex)); + }); + }, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/navigation.dart b/lib/widgets/settings/navigation/navigation.dart similarity index 96% rename from lib/widgets/settings/navigation.dart rename to lib/widgets/settings/navigation/navigation.dart index 9edd83c1d..50f8252fa 100644 --- a/lib/widgets/settings/navigation.dart +++ b/lib/widgets/settings/navigation/navigation.dart @@ -8,6 +8,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; +import 'package:aves/widgets/settings/navigation/drawer.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -51,6 +52,7 @@ class NavigationSection extends StatelessWidget { } }, ), + const NavigationDrawerTile(), ListTile( title: Text(context.l10n.settingsKeepScreenOnTile), subtitle: Text(currentKeepScreenOn.getName(context)), diff --git a/lib/widgets/settings/privacy/hidden_paths.dart b/lib/widgets/settings/privacy/hidden_paths.dart index 5da86e9af..b2f44a10f 100644 --- a/lib/widgets/settings/privacy/hidden_paths.dart +++ b/lib/widgets/settings/privacy/hidden_paths.dart @@ -40,7 +40,7 @@ class HiddenPathPage extends StatelessWidget { title: Text(context.l10n.settingsHiddenPathsTitle), actions: [ IconButton( - icon: const Icon(AIcons.addPath), + icon: const Icon(AIcons.add), onPressed: () async { final path = await storageService.selectDirectory(); if (path != null && path.isNotEmpty) { @@ -87,7 +87,7 @@ class HiddenPathPage extends StatelessWidget { onPressed: () { context.read().changeFilterVisibility({pathFilter}, true); }, - tooltip: context.l10n.settingsHiddenPathsRemoveTooltip, + tooltip: context.l10n.removeTooltip, ), )), ], diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index 6ca13f610..e226f5773 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -8,11 +8,11 @@ import 'package:aves/services/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/feedback.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/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/settings/language/language.dart'; -import 'package:aves/widgets/settings/navigation.dart'; +import 'package:aves/widgets/settings/navigation/navigation.dart'; import 'package:aves/widgets/settings/privacy/privacy.dart'; import 'package:aves/widgets/settings/thumbnails.dart'; import 'package:aves/widgets/settings/video/video.dart'; @@ -42,23 +42,25 @@ class _SettingsPageState extends State with FeedbackMixin { appBar: AppBar( title: Text(context.l10n.settingsPageTitle), actions: [ - PopupMenuButton( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: SettingsAction.export, - child: MenuRow(text: context.l10n.settingsActionExport, icon: AIcons.export), - ), - PopupMenuItem( - value: SettingsAction.import, - child: MenuRow(text: context.l10n.settingsActionImport, icon: AIcons.import), - ), - ]; - }, - onSelected: (action) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action)); - }, + MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: SettingsAction.export, + child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.export)), + ), + PopupMenuItem( + value: SettingsAction.import, + child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.import)), + ), + ]; + }, + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(action)); + }, + ), ), ], ), diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index b48570386..1e2aa466f 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -28,17 +28,17 @@ class StatsPage extends StatelessWidget { final CollectionSource source; final CollectionLens? parentCollection; - late final Set entries; + final Set entries; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; static const mimeDonutMinWidth = 124.0; StatsPage({ Key? key, + required this.entries, required this.source, this.parentCollection, }) : super(key: key) { - entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries; entries.forEach((entry) { if (entry.hasAddress) { final address = entry.addressDetails!; diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 94a717100..9fe1daf08 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -135,6 +135,7 @@ class _OwnerPropState extends State { AvesEntry get entry => widget.entry; + static const ownerPackageNamePropKey = 'owner_package_name'; static const iconSize = 20.0; @override @@ -142,7 +143,9 @@ class _OwnerPropState extends State { super.initState(); final isMediaContent = entry.uri.startsWith('content://media/external/'); if (isMediaContent) { - _ownerPackageFuture = metadataService.getContentResolverProp(entry, 'owner_package_name'); + _ownerPackageFuture = metadataService.hasContentResolverProp(ownerPackageNamePropKey).then((exists) { + return exists ? metadataService.getContentResolverProp(entry, ownerPackageNamePropKey) : SynchronousFuture(null); + }); } else { _ownerPackageFuture = SynchronousFuture(null); } @@ -165,23 +168,20 @@ class _OwnerPropState extends State { text: context.l10n.viewerInfoLabelOwner, style: InfoRowGroup.keyStyle, ), - // `com.android.shell` is the package reported - // for images copied to the device by ADB for Test Driver - if (ownerPackage != 'com.android.shell') - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Image( - image: AppIconImage( - packageName: ownerPackage, - size: iconSize, - ), - width: iconSize, - height: iconSize, + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Image( + image: AppIconImage( + packageName: ownerPackage, + size: iconSize, ), + width: iconSize, + height: iconSize, ), ), + ), TextSpan( text: appName, style: InfoRowGroup.baseStyle, diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 60c2343fd..181d69277 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -107,7 +107,7 @@ class _ViewerBottomOverlayState extends State { _lastDetails = snapshot.data; _lastEntry = entry; } - if (_lastEntry == null) return const SizedBox.shrink(); + if (_lastEntry == null) return const SizedBox(); final mainEntry = _lastEntry!; Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( @@ -261,7 +261,7 @@ class _BottomOverlayContent extends AnimatedWidget { padding: const EdgeInsets.only(top: _interRowPadding), child: _LocationRow(entry: pageEntry), ) - : const SizedBox.shrink(), + : const SizedBox(), ); Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( @@ -275,7 +275,7 @@ class _BottomOverlayContent extends AnimatedWidget { width: subRowWidth, child: _ShootingRow(details!), ) - : const SizedBox.shrink(), + : const SizedBox(), ); Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( @@ -291,7 +291,7 @@ class _BottomOverlayContent extends AnimatedWidget { width: subRowWidth, child: _ShootingRow(details!), ) - : const SizedBox.shrink(), + : const SizedBox(), ); static Widget _soloTransition(Widget child, Animation animation) => FadeTransition( diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index e599f9a61..5630a7fe1 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -1,12 +1,7 @@ -import 'dart:math'; - import 'package:aves/model/multipage.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -25,22 +20,14 @@ class MultiPageOverlay extends StatefulWidget { } class _MultiPageOverlayState extends State { - final _cancellableNotifier = ValueNotifier(true); - late ScrollController _scrollController; - bool _syncScroll = true; - int? _initControllerPage; - - static const double extent = 48; - static const double separatorWidth = 2; + int? _previousPage; MultiPageController get controller => widget.controller; - double get availableWidth => widget.availableWidth; - @override void initState() { super.initState(); - _registerWidget(); + _registerWidget(widget); } @override @@ -48,136 +35,50 @@ class _MultiPageOverlayState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.controller != controller) { - _unregisterWidget(); - _registerWidget(); + _unregisterWidget(oldWidget); + _registerWidget(widget); } } @override void dispose() { - _unregisterWidget(); + _unregisterWidget(widget); super.dispose(); } - void _registerWidget() { - _initControllerPage = controller.page; - final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0); - _scrollController = ScrollController(initialScrollOffset: scrollOffset); - _scrollController.addListener(_onScrollChange); - - if (_initControllerPage == null) { - _correctDefaultPageScroll(); - } + void _registerWidget(MultiPageOverlay widget) { + widget.controller.pageNotifier.addListener(_onPageChange); } - // correct scroll offset to match default page - // if default page was unknown when the scroll controller was created - void _correctDefaultPageScroll() async { - await controller.infoStream.first; - if (_initControllerPage == null) { - _initControllerPage = controller.page; - if (_initControllerPage != null && _initControllerPage != 0) { - WidgetsBinding.instance!.addPostFrameCallback((_) => _goToPage(_initControllerPage!)); - } - } - } - - void _unregisterWidget() { - _scrollController.removeListener(_onScrollChange); - _scrollController.dispose(); + void _unregisterWidget(MultiPageOverlay widget) { + widget.controller.pageNotifier.removeListener(_onPageChange); } @override Widget build(BuildContext context) { - final marginWidth = max(0.0, (availableWidth - extent) / 2 - separatorWidth); - final horizontalMargin = SizedBox(width: marginWidth); - const separator = SizedBox(width: separatorWidth); - - return GridTheme( - extent: extent, - showLocation: false, - child: StreamBuilder( - stream: controller.infoStream, - builder: (context, snapshot) { - final multiPageInfo = controller.info; - final pageCount = multiPageInfo?.pageCount ?? 0; - return SizedBox( - height: extent, - child: ListView.separated( - key: ValueKey(multiPageInfo), - 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 == pageCount + 1) return horizontalMargin; - final page = index - 1; - final pageEntry = multiPageInfo!.getPageEntryByIndex(page); - - return Stack( - children: [ - GestureDetector( - onTap: () => _goToPage(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: AnimatedContainer( - color: controller.page == page ? Colors.transparent : Colors.black45, - width: extent, - height: extent, - duration: Durations.viewerOverlayPageShadeAnimation, - ), - ) - ], - ); - }, - separatorBuilder: (context, index) => separator, - itemCount: pageCount + 2, - ), - ); - }, - ), + return StreamBuilder( + stream: controller.infoStream, + builder: (context, snapshot) { + final multiPageInfo = controller.info; + return ThumbnailScroller( + key: ValueKey(multiPageInfo), + availableWidth: widget.availableWidth, + entryCount: multiPageInfo?.pageCount ?? 0, + entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page), + indexNotifier: controller.pageNotifier, + ); + }, ); } - void _setPage(int newPage) { - final oldPage = controller.page; - if (oldPage == newPage) return; - - final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage); - controller.page = newPage; - context.read().reset(oldPageEntry); - } - - Future _goToPage(int page) async { - _syncScroll = false; - _setPage(page); - await _scrollController.animateTo( - pageToScrollOffset(page), - duration: Durations.viewerOverlayPageScrollAnimation, - curve: Curves.easeOutCubic, - ); - _syncScroll = true; - } - - void _onScrollChange() { - if (_syncScroll) { - _setPage(scrollOffsetToPage(_scrollController.offset)); + void _onPageChange() { + if (_previousPage != null) { + final info = controller.info; + if (info != null) { + final oldPageEntry = info.getPageEntryByIndex(_previousPage); + context.read().reset(oldPageEntry); + } } + _previousPage = controller.page; } - - double pageToScrollOffset(int page) => page * (extent + separatorWidth); - - int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round(); } diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 89aceb7f3..8014e9cff 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/time_utils.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/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/borders.dart'; @@ -224,12 +224,14 @@ class _ButtonRow extends StatelessWidget { padding: const EdgeInsetsDirectional.only(start: padding), child: OverlayButton( scale: scale, - child: PopupMenuButton( - itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), - onSelected: (action) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); - }, + child: MenuIconTheme( + child: PopupMenuButton( + itemBuilder: (context) => menuActions.map((action) => _buildPopupMenuItem(context, action)).toList(), + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => onActionSelected(action)); + }, + ), ), ), ), @@ -249,7 +251,7 @@ class _ButtonRow extends StatelessWidget { onPressed: canDo ? onPressed : null, tooltip: action.getText(context), ), - child: Icon(action.getIcon()), + child: action.getIcon(), ); } @@ -273,7 +275,7 @@ class _ButtonRow extends StatelessWidget { case VideoAction.skip10: case VideoAction.settings: child = IconButton( - icon: Icon(action.getIcon()), + icon: action.getIcon(), onPressed: onPressed, tooltip: action.getText(context), ); @@ -401,11 +403,11 @@ class _PlayTogglerState extends State<_PlayToggler> with SingleTickerProviderSta return isPlaying ? MenuRow( text: context.l10n.videoActionPause, - icon: AIcons.pause, + icon: const Icon(AIcons.pause), ) : MenuRow( text: context.l10n.videoActionPlay, - icon: AIcons.play, + icon: const Icon(AIcons.play), ); } return IconButton( diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index ba720cc13..8244c8d2c 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -4,7 +4,7 @@ import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.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/common/fx/sweeper.dart'; import 'package:aves/widgets/viewer/entry_action_delegate.dart'; @@ -166,22 +166,24 @@ class _TopOverlayRow extends StatelessWidget { ...quickActions.map((action) => _buildOverlayButton(context, action)), OverlayButton( scale: scale, - child: PopupMenuButton( - key: const Key('entry-menu-button'), - itemBuilder: (context) => [ - ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), - const PopupMenuDivider(), - ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), - if (!kReleaseMode) ...[ + child: MenuIconTheme( + child: PopupMenuButton( + key: const Key('entry-menu-button'), + itemBuilder: (context) => [ + ...inAppActions.map((action) => _buildPopupMenuItem(context, action)), + if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context), const PopupMenuDivider(), - _buildPopupMenuItem(context, EntryAction.debug), - ] - ], - onSelected: (action) { - // wait for the popup menu to hide before proceeding with the action - Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); - }, + ...externalAppActions.map((action) => _buildPopupMenuItem(context, action)), + if (!kReleaseMode) ...[ + const PopupMenuDivider(), + _buildPopupMenuItem(context, EntryAction.debug), + ] + ], + onSelected: (action) { + // wait for the popup menu to hide before proceeding with the action + Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action)); + }, + ), ), ), ], @@ -212,7 +214,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.viewSource: case EntryAction.viewMotionPhotoVideo: child = IconButton( - icon: Icon(action.getIcon()), + icon: action.getIcon() ?? const SizedBox(), onPressed: onPressed, tooltip: action.getText(context), ); @@ -269,7 +271,7 @@ class _TopOverlayRow extends StatelessWidget { value: action, child: Tooltip( message: action.getText(context), - child: Center(child: Icon(action.getIcon())), + child: Center(child: action.getIcon()), ), ), ); @@ -351,11 +353,11 @@ class _FavouriteTogglerState extends State<_FavouriteToggler> { return isFavourite ? MenuRow( text: context.l10n.entryActionRemoveFavourite, - icon: AIcons.favouriteActive, + icon: const Icon(AIcons.favouriteActive), ) : MenuRow( text: context.l10n.entryActionAddFavourite, - icon: AIcons.favourite, + icon: const Icon(AIcons.favourite), ); } return Stack( diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index a85e663fe..452d65f07 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -3,13 +3,13 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/magnifier.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; diff --git a/pubspec.lock b/pubspec.lock index b060c499d..8bdff5bdf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -981,7 +981,7 @@ packages: source: hosted version: "0.3.19" transparent_image: - dependency: transitive + dependency: "direct main" description: name: transparent_image url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index e8c22c1d3..650ad649c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.4.8+52 +version: 1.4.9+53 publish_to: none environment: @@ -59,6 +59,7 @@ dependencies: streams_channel: git: url: git://github.com/deckerst/aves_streams_channel.git + transparent_image: tuple: url_launcher: version: diff --git a/scripts/fix_android_log_levels.bat b/scripts/fix_android_log_levels.bat index 520b0df2d..12d5967b2 100644 --- a/scripts/fix_android_log_levels.bat +++ b/scripts/fix_android_log_levels.bat @@ -14,6 +14,8 @@ adb.exe shell setprop log.tag.AudioTrack INFO adb.exe shell setprop log.tag.CompatibilityChangeReporter INFO adb.exe shell setprop log.tag.Counters WARN adb.exe shell setprop log.tag.CustomizedTextParser INFO +adb.exe shell setprop log.tag.EGL_emulation INFO +adb.exe shell setprop log.tag.HostConnection INFO adb.exe shell setprop log.tag.InputMethodManager WARN adb.exe shell setprop log.tag.InsetsSourceConsumer INFO adb.exe shell setprop log.tag.InputTransport INFO @@ -22,6 +24,7 @@ adb.exe shell setprop log.tag.MediaCodec WARN adb.exe shell setprop log.tag.MediaMetadataRetriever INFO adb.exe shell setprop log.tag.MediaMetadataRetrieverJNI INFO adb.exe shell setprop log.tag.NuMediaExtractor INFO +adb.exe shell setprop log.tag.skia INFO adb.exe shell setprop log.tag.SurfaceControl WARN adb.exe shell setprop log.tag.SurfaceUtils INFO adb.exe shell setprop log.tag.SurfaceView WARN diff --git a/scripts/fix_android_log_levels.sh b/scripts/fix_android_log_levels.sh new file mode 100755 index 000000000..3771741a1 --- /dev/null +++ b/scripts/fix_android_log_levels.sh @@ -0,0 +1,23 @@ +#!/bin/bash +adb shell setprop log.tag.ACodec WARN +adb shell setprop log.tag.AHierarchicalStateMachine ERROR +adb shell setprop log.tag.AudioCapabilities ERROR +adb shell setprop log.tag.AudioTrack INFO +adb shell setprop log.tag.CompatibilityChangeReporter INFO +adb shell setprop log.tag.Counters WARN +adb shell setprop log.tag.CustomizedTextParser INFO +adb shell setprop log.tag.EGL_emulation INFO +adb shell setprop log.tag.HostConnection INFO +adb shell setprop log.tag.InputMethodManager WARN +adb shell setprop log.tag.InsetsSourceConsumer INFO +adb shell setprop log.tag.InputTransport INFO +adb shell setprop log.tag.J4A INFO +adb shell setprop log.tag.MediaCodec WARN +adb shell setprop log.tag.MediaMetadataRetriever INFO +adb shell setprop log.tag.MediaMetadataRetrieverJNI INFO +adb shell setprop log.tag.NuMediaExtractor INFO +adb shell setprop log.tag.skia INFO +adb shell setprop log.tag.SurfaceControl WARN +adb shell setprop log.tag.SurfaceUtils INFO +adb shell setprop log.tag.SurfaceView WARN +adb shell setprop log.tag.VideoCapabilities ERROR diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart index c04a205d3..91709081e 100644 --- a/test_driver/app_test.dart +++ b/test_driver/app_test.dart @@ -122,12 +122,14 @@ void selectFirstAlbum() { await driver.tap(find.byValueKey('appbar-leading-button')); await driver.waitUntilNoTransientCallbacks(); - await driver.tap(find.byValueKey('Albums-tile')); + // prefix must match `AlbumListPage.routeName` + await driver.tap(find.byValueKey('/albums-tile')); await driver.waitUntilNoTransientCallbacks(); // wait for collection loading await driver.waitForCondition(const NoPendingPlatformMessages()); + // TODO TLAD fix finder await driver.tap(find.descendant( of: find.byValueKey('filter-grid-page'), matching: find.byType('CoveredFilterChip'), diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index 2eff3dbb6..0f2063e55 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,6 +1,7 @@ Thanks for using Aves! -v1.4.8: -- map page -- viewer action to copy to clipboard -- integration with OS global search +v1.4.9: +- open the map or get stats for selected items +- browse and navigate to items on the map +- customize the navigation menu +- create shortcuts on Android Nougat and older Full changelog available on Github \ No newline at end of file