Merge commit '514b4ce0f0943052b877419fced578214207ed8a'

This commit is contained in:
Thibault Deckers 2021-08-20 17:34:47 +09:00
commit ea9a271f36
124 changed files with 2505 additions and 1238 deletions

View file

@ -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

View file

@ -21,7 +21,7 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
- search and filter by country, place, XMP tag, type (animated, raster, vector…)
- favorites
- statistics
- support Android API 20 ~ 30 (Lollipop ~ R)
- support Android API 20 ~ 31 (Lollipop ~ S)
- Android integration (app shortcuts, handle view/pick intents)
## Project Setup
@ -29,4 +29,4 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys.
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Release%20on%20tag
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -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'

View file

@ -30,6 +30,9 @@
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- for API < 26 -->
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<!-- from Android R, we should define <queries> to make other apps visible to this app -->
<queries>
<intent>

View file

@ -119,7 +119,9 @@ class MainActivity : FlutterActivity() {
when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode)
DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode)
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onPermissionResult(requestCode, data?.data)
CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST,
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
}
}
@ -127,7 +129,7 @@ class MainActivity : FlutterActivity() {
private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) {
onPermissionResult(requestCode, null)
onStorageAccessResult(requestCode, null)
return
}
@ -138,7 +140,7 @@ class MainActivity : FlutterActivity() {
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
// resume pending action
onPermissionResult(requestCode, treeUri)
onStorageAccessResult(requestCode, treeUri)
}
private fun onDeletePermissionResult(resultCode: Int) {
@ -152,9 +154,17 @@ class MainActivity : FlutterActivity() {
when (intent?.action) {
Intent.ACTION_MAIN -> {
intent.getStringExtra("page")?.let { page ->
var filters = intent.getStringArrayExtra("filters")?.toList()
if (filters == null) {
// fallback for shortcuts created on API < 26
val filterString = intent.getStringExtra("filtersString")
if (filterString != null) {
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
}
return hashMapOf(
"page" to page,
"filters" to intent.getStringArrayExtra("filters")?.toList(),
"filters" to filters,
)
}
}
@ -209,9 +219,13 @@ class MainActivity : FlutterActivity() {
private fun setupShortcuts() {
// do not use 'route' as extra key, as the Flutter framework acts on it
// shortcut adaptive icons are placed in `mipmap`, not `drawable`,
// so that foreground is rendered at the intended scale
val supportAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
val search = ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/search")
@ -220,7 +234,7 @@ class MainActivity : FlutterActivity() {
val videos = ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("page", "/collection")
@ -234,18 +248,19 @@ class MainActivity : FlutterActivity() {
companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
const val DOCUMENT_TREE_ACCESS_REQUEST = 1
const val DELETE_PERMISSION_REQUEST = 2
const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5
// permission request code to pending runnable
val pendingResultHandlers = ConcurrentHashMap<Int, PendingResultHandler>()
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
fun onPermissionResult(requestCode: Int, uri: Uri?) {
Log.d(LOG_TAG, "onPermissionResult with requestCode=$requestCode, uri=$uri")
val handler = pendingResultHandlers.remove(requestCode) ?: return
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
if (uri != null) {
handler.onGranted(uri)
} else {
@ -261,4 +276,4 @@ class MainActivity : FlutterActivity() {
// onGranted: user selected a directory/file (with no guarantee that it matches the requested `path`)
// onDenied: user cancelled
data class PendingResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)
data class PendingStorageAccessResultHandler(val path: String?, val onGranted: (uri: Uri) -> Unit, val onDenied: () -> Unit)

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -5,6 +5,7 @@ import android.content.Context
import android.database.Cursor
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
@ -77,6 +78,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
else -> result.notImplemented()
}
@ -681,6 +683,24 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
}
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument<String>("prop")
if (prop == null) {
result.error("hasContentResolverProp-args", "failed because of missing arguments", null)
return
}
result.success(
when (prop) {
"owner_package_name" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
else -> {
result.error("hasContentResolverProp-unknown", "unknown property=$prop", null)
return
}
}
)
}
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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()
}, {

View file

@ -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) {

View file

@ -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
}

View file

@ -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")

View file

@ -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) {

View file

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z"/>
</group>
</vector>

View file

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z"/>
</group>
</vector>

View file

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
android:tint="#455A64">
<group android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/ic_shortcut_background"
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
<group
android:translateX="12"
android:translateY="12">
<path
android:fillColor="@color/ic_shortcut_foreground"
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/ic_shortcut_background"
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
<group
android:translateX="12"
android:translateY="12">
<path
android:fillColor="@color/ic_shortcut_foreground"
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z" />
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@color/ic_shortcut_background"
android:pathData="M0,24 A1,1 0 1,1 48,24 A1,1 0 1,1 0,24" />
<group
android:translateX="12"
android:translateY="12">
<path
android:fillColor="@color/ic_shortcut_foreground"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="@color/ic_shortcut_foreground"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4v12L8,16L8,4h12m0,-2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM11.5,11.67l1.69,2.26 2.48,-3.1L19,15L9,15zM2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6L2,6z" />
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="@color/ic_shortcut_foreground"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M4,6.47L5.76,10H20v8H4V6.47M22,4h-4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4z" />
</group>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:tint="@color/ic_shortcut_foreground"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="1.7226"
android:scaleY="1.7226"
android:translateX="33.3288"
android:translateY="33.3288">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</group>
</vector>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_collection_foreground"/>
<background android:drawable="@color/ic_shortcut_background" />
<foreground android:drawable="@drawable/ic_shortcut_collection_foreground" />
</adaptive-icon>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_movie_foreground"/>
<background android:drawable="@color/ic_shortcut_background" />
<foreground android:drawable="@drawable/ic_shortcut_movie_foreground" />
</adaptive-icon>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_shortcut_background"/>
<foreground android:drawable="@drawable/ic_shortcut_search_foreground"/>
<background android:drawable="@color/ic_shortcut_background" />
<foreground android:drawable="@drawable/ic_shortcut_search_foreground" />
</adaptive-icon>

View file

@ -2,5 +2,6 @@
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
<color name="ic_shortcut_background">#FFFFFF</color>
<color name="ic_shortcut_foreground">#455A64</color>
<color name="ic_launcher_flavour">#3f51b5</color>
</resources>

View file

@ -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'
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/services/android_app_service.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:transparent_image/transparent_image.dart';
class AppIconImage extends ImageProvider<AppIconImageKey> {
const AppIconImage({
@ -39,10 +40,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
if (bytes.isEmpty) {
throw StateError('$packageName app icon loading failed');
}
return await decode(bytes);
return await decode(bytes.isEmpty ? kTransparentImage : bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
throw StateError('$packageName app icon decoding failed');

View file

@ -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": {},

View file

@ -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": "저장공간 접근",

View file

@ -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;

View file

@ -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;

View file

@ -1,14 +0,0 @@
enum CollectionAction {
addShortcut,
sort,
group,
select,
selectAll,
selectNone,
map,
stats,
// apply to entry set
copy,
move,
refreshMetadata,
}

View file

@ -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

View file

@ -0,0 +1,89 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum EntrySetAction {
// general
sort,
group,
select,
selectAll,
selectNone,
// all
addShortcut,
// all or entry selection
map,
stats,
// entry selection
copy,
move,
refreshMetadata,
}
extension ExtraEntrySetAction on EntrySetAction {
String getText(BuildContext context) {
switch (this) {
// general
case EntrySetAction.sort:
return context.l10n.menuActionSort;
case EntrySetAction.group:
return context.l10n.menuActionGroup;
case EntrySetAction.select:
return context.l10n.menuActionSelect;
case EntrySetAction.selectAll:
return context.l10n.menuActionSelectAll;
case EntrySetAction.selectNone:
return context.l10n.menuActionSelectNone;
// all
case EntrySetAction.addShortcut:
return context.l10n.collectionActionAddShortcut;
// all or entry selection
case EntrySetAction.map:
return context.l10n.menuActionMap;
case EntrySetAction.stats:
return context.l10n.menuActionStats;
// entry selection
case EntrySetAction.copy:
return context.l10n.collectionActionCopy;
case EntrySetAction.move:
return context.l10n.collectionActionMove;
case EntrySetAction.refreshMetadata:
return context.l10n.collectionActionRefreshMetadata;
}
}
Widget getIcon() {
return Icon(_getIconData());
}
IconData _getIconData() {
switch (this) {
// general
case EntrySetAction.sort:
return AIcons.sort;
case EntrySetAction.group:
return AIcons.group;
case EntrySetAction.select:
return AIcons.select;
case EntrySetAction.selectAll:
return AIcons.selected;
case EntrySetAction.selectNone:
return AIcons.unselected;
// all
case EntrySetAction.addShortcut:
return AIcons.addShortcut;
// all or entry selection
case EntrySetAction.map:
return AIcons.map;
case EntrySetAction.stats:
return AIcons.stats;
// entry selection
case EntrySetAction.copy:
return AIcons.copy;
case EntrySetAction.move:
return AIcons.move;
case EntrySetAction.refreshMetadata:
return AIcons.refresh;
}
}
}

View file

@ -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;

View file

@ -43,15 +43,6 @@ class AvesEntry {
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const List<String> undecodable = [
MimeTypes.art,
MimeTypes.crw,
MimeTypes.djvu,
MimeTypes.psdVnd,
MimeTypes.psdX,
];
AvesEntry({
required this.uri,
required String? path,
@ -74,7 +65,7 @@ class AvesEntry {
this.durationMillis = durationMillis;
}
bool get canDecode => !undecodable.contains(mimeType);
bool get canDecode => !MimeTypes.undecodableImages.contains(mimeType);
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
@ -238,9 +229,10 @@ class AvesEntry {
bool get canRotateAndFlip => canEdit && canEditExif;
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
// as of androidx.exifinterface:exifinterface:1.3.3
bool get canEditExif {
switch (mimeType.toLowerCase()) {
case MimeTypes.dng:
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.webp:

View file

@ -5,41 +5,45 @@ class Selection<T> extends ChangeNotifier {
bool get isSelecting => _isSelecting;
final Set<T> _selection = {};
final Set<T> _selectedItems = {};
Set<T> get selection => _selection;
Set<T> get selectedItems => _selectedItems;
void browse() {
if (!_isSelecting) return;
clearSelection();
_isSelecting = false;
notifyListeners();
}
void select() {
if (_isSelecting) return;
_isSelecting = true;
notifyListeners();
}
bool isSelected(Iterable<T> items) => items.every(selection.contains);
bool isSelected(Iterable<T> items) => items.every(selectedItems.contains);
void addToSelection(Iterable<T> items) {
_selection.addAll(items);
if (items.isEmpty) return;
_selectedItems.addAll(items);
notifyListeners();
}
void removeFromSelection(Iterable<T> items) {
_selection.removeAll(items);
if (items.isEmpty) return;
_selectedItems.removeAll(items);
notifyListeners();
}
void clearSelection() {
_selection.clear();
_selectedItems.clear();
notifyListeners();
}
void toggleSelection(T item) {
if (_selection.isEmpty) select();
if (!_selection.remove(item)) _selection.add(item);
if (_selectedItems.isEmpty) select();
if (!_selectedItems.remove(item)) _selectedItems.add(item);
notifyListeners();
}
}

View file

@ -3,7 +3,9 @@ import 'dart:math';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/screen_on.dart';
@ -11,11 +13,13 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/services/device_service.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private();
@ -47,6 +51,11 @@ class Settings extends ChangeNotifier {
static const catalogTimeZoneKey = 'catalog_time_zone';
static const tileExtentPrefixKey = 'tile_extent_';
// drawer
static const drawerTypeBookmarksKey = 'drawer_type_bookmarks';
static const drawerAlbumBookmarksKey = 'drawer_album_bookmarks';
static const drawerPageBookmarksKey = 'drawer_page_bookmarks';
// collection
static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor';
@ -100,6 +109,16 @@ class Settings extends ChangeNotifier {
static const lastVersionCheckDateKey = 'last_version_check_date';
// defaults
static final drawerTypeBookmarksDefault = [
null,
MimeFilter.video,
FavouriteFilter.instance,
];
static final drawerPageBookmarksDefault = [
AlbumListPage.routeName,
CountryListPage.routeName,
TagListPage.routeName,
];
static const viewerQuickActionsDefault = [
EntryAction.toggleFavourite,
EntryAction.share,
@ -209,6 +228,25 @@ class Settings extends ChangeNotifier {
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
// drawer
List<CollectionFilter?> get drawerTypeBookmarks =>
(_prefs!.getStringList(drawerTypeBookmarksKey))?.map((v) {
if (v.isEmpty) return null;
return CollectionFilter.fromJson(v);
}).toList() ??
drawerTypeBookmarksDefault;
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
List<String>? get drawerAlbumBookmarks => _prefs!.getStringList(drawerAlbumBookmarksKey);
set drawerAlbumBookmarks(List<String>? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue);
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? drawerPageBookmarksDefault;
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
// collection
EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
@ -447,7 +485,9 @@ class Settings extends ChangeNotifier {
// apply user modifications
jsonMap.forEach((key, value) {
if (key.startsWith(tileExtentPrefixKey)) {
if (value == null) {
_prefs!.remove(key);
} else if (key.startsWith(tileExtentPrefixKey)) {
if (value is double) {
_prefs!.setDouble(key, value);
} else {
@ -511,6 +551,9 @@ class Settings extends ChangeNotifier {
debugPrint('failed to import key=$key, value=$value is not a string');
}
break;
case drawerTypeBookmarksKey:
case drawerAlbumBookmarksKey:
case drawerPageBookmarksKey:
case pinnedFiltersKey:
case hiddenFiltersKey:
case viewerQuickActionsKey:

View file

@ -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;
}
}

View file

@ -159,6 +159,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
final oldFilter = AlbumFilter(sourceAlbum, null);
final bookmarked = settings.drawerAlbumBookmarks?.contains(sourceAlbum) == true;
final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
@ -169,8 +170,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
// restore pin and cover, as the obsolete album got removed and its associated state cleaned
// restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned
final newFilter = AlbumFilter(destinationAlbum, null);
if (bookmarked) {
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum);
}
if (pinned) {
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}

View file

@ -41,18 +41,29 @@ class MimeTypes {
static const anyVideo = 'video/*';
static const avi = 'video/avi';
static const mkv = 'video/x-matroska';
static const mov = 'video/quicktime';
static const mp2t = 'video/mp2t'; // .m2ts
static const mp4 = 'video/mp4';
static const ogg = 'video/ogg';
static const json = 'application/json';
// groups
// formats that support transparency
static const List<String> alphaImages = [bmp, gif, ico, png, svg, tiff, webp];
static const Set<String> alphaImages = {bmp, gif, ico, png, svg, tiff, webp};
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
static const Set<String> rawImages = {arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f};
// TODO TLAD make it dynamic if it depends on OS/lib versions
static const Set<String> undecodableImages = {art, crw, djvu, psdVnd, psdX};
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
static const Set<String> _knownVideos = {avi, mkv, mov, mp2t, mp4, ogg};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};
static bool isImage(String mimeType) => mimeType.startsWith('image');

View file

@ -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;
}

View file

@ -9,40 +9,40 @@ class AndroidDebugService {
static Future<void> crash() async {
try {
await platform.invokeMethod('crash');
} on PlatformException catch (e) {
await reportService.recordChannelError('crash', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> exception() async {
try {
await platform.invokeMethod('exception');
} on PlatformException catch (e) {
await reportService.recordChannelError('exception', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> safeException() async {
try {
await platform.invokeMethod('safeException');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeException', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> exceptionInCoroutine() async {
try {
await platform.invokeMethod('exceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('exceptionInCoroutine', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> safeExceptionInCoroutine() async {
try {
await platform.invokeMethod('safeExceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeExceptionInCoroutine', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@ -50,8 +50,8 @@ class AndroidDebugService {
try {
final result = await platform.invokeMethod('getContextDirs');
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getContextDirs', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -60,8 +60,8 @@ class AndroidDebugService {
try {
final result = await platform.invokeMethod('getEnv');
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getEnv', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -73,8 +73,8 @@ class AndroidDebugService {
'uri': entry.uri,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getBitmapFactoryInfo', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -87,8 +87,8 @@ class AndroidDebugService {
'uri': entry.uri,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getContentResolverMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -102,8 +102,8 @@ class AndroidDebugService {
'sizeBytes': entry.sizeBytes,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getExifInterfaceMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -115,8 +115,8 @@ class AndroidDebugService {
'uri': entry.uri,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -130,8 +130,8 @@ class AndroidDebugService {
'sizeBytes': entry.sizeBytes,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getMetadataExtractorSummary', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -144,8 +144,8 @@ class AndroidDebugService {
'uri': entry.uri,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getTiffStructure', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}

View file

@ -13,9 +13,7 @@ class AppShortcutService {
static bool? _canPin;
static Future<bool> canPin() async {
if (_canPin != null) {
return SynchronousFuture(_canPin!);
}
if (_canPin != null) return SynchronousFuture(_canPin!);
try {
final result = await platform.invokeMethod('canPin');
@ -23,8 +21,8 @@ class AppShortcutService {
_canPin = result;
return result;
}
} on PlatformException catch (e) {
await reportService.recordChannelError('canPin', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@ -49,8 +47,8 @@ class AppShortcutService {
'iconBytes': iconBytes,
'filters': filters.map((filter) => filter.toJson()).toList(),
});
} on PlatformException catch (e) {
await reportService.recordChannelError('pin', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -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;
}

View file

@ -26,8 +26,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'sizeBytes': entry.sizeBytes,
});
if (result != null) return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {
await reportService.recordChannelError('getExifThumbnail', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@ -42,8 +42,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'displayName': '${entry.bestTitle} • Video',
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('extractMotionPhotoVideo', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -56,8 +56,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'displayName': '${entry.bestTitle} • Cover',
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('extractVideoEmbeddedPicture', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -74,8 +74,8 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
'propMimeType': propMimeType,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('extractXmpDataProp', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}

View file

@ -20,8 +20,8 @@ class GeocodingService {
'maxResults': 2,
});
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
} on PlatformException catch (e) {
await reportService.recordChannelError('getAddress', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}

View file

@ -14,8 +14,8 @@ class GlobalSearch {
await platform.invokeMethod('registerCallback', <String, dynamic>{
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
});
} on PlatformException catch (e) {
await reportService.recordChannelError('registerCallback', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/output_buffer.dart';
import 'package:aves/services/service_policy.dart';
@ -124,8 +125,8 @@ class PlatformImageFileService implements ImageFileService {
'mimeType': mimeType,
}) as Map;
return AvesEntry.fromMap(result);
} on PlatformException catch (e) {
await reportService.recordChannelError('getEntry', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -188,8 +189,8 @@ class PlatformImageFileService implements ImageFileService {
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
reportService.recordChannelError('getImage', e);
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
}
return Future.sync(() => Uint8List(0));
}
@ -223,8 +224,8 @@ class PlatformImageFileService implements ImageFileService {
'imageHeight': imageSize.height.toInt(),
});
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
await reportService.recordChannelError('getRegion', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return Uint8List(0);
},
@ -260,8 +261,10 @@ class PlatformImageFileService implements ImageFileService {
'defaultSizeDip': thumbnailDefaultSize,
});
if (result != null) return result as Uint8List;
} on PlatformException catch (e) {
await reportService.recordChannelError('getThumbnail', e);
} on PlatformException catch (e, stack) {
if (!MimeTypes.knownMediaTypes.contains(mimeType)) {
await reportService.recordError(e, stack);
}
}
return Uint8List(0);
},
@ -274,8 +277,8 @@ class PlatformImageFileService implements ImageFileService {
Future<void> clearSizedThumbnailDiskCache() async {
try {
return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) {
await reportService.recordChannelError('clearSizedThumbnailDiskCache', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@ -295,8 +298,8 @@ class PlatformImageFileService implements ImageFileService {
'op': 'delete',
'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e) {
reportService.recordChannelError('delete', e);
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@ -314,8 +317,8 @@ class PlatformImageFileService implements ImageFileService {
'copy': copy,
'destinationPath': destinationAlbum,
}).map((event) => MoveOpEvent.fromMap(event));
} on PlatformException catch (e) {
reportService.recordChannelError('move', e);
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@ -333,8 +336,8 @@ class PlatformImageFileService implements ImageFileService {
'mimeType': mimeType,
'destinationPath': destinationAlbum,
}).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e) {
reportService.recordChannelError('export', e);
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@ -356,8 +359,8 @@ class PlatformImageFileService implements ImageFileService {
'destinationPath': destinationAlbum,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
await reportService.recordChannelError('captureFrame', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -371,8 +374,8 @@ class PlatformImageFileService implements ImageFileService {
'newName': newName,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
await reportService.recordChannelError('rename', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -386,8 +389,8 @@ class PlatformImageFileService implements ImageFileService {
'clockwise': clockwise,
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
await reportService.recordChannelError('rotate', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -400,8 +403,8 @@ class PlatformImageFileService implements ImageFileService {
'entry': _toPlatformEntryMap(entry),
});
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
await reportService.recordChannelError('flip', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}

View file

@ -25,8 +25,8 @@ class PlatformMediaStoreService implements MediaStoreService {
'knownContentIds': knownContentIds,
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
await reportService.recordChannelError('checkObsoleteContentIds', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@ -38,8 +38,8 @@ class PlatformMediaStoreService implements MediaStoreService {
'knownPathById': knownPathById,
});
return (result as List).cast<int>();
} on PlatformException catch (e) {
await reportService.recordChannelError('checkObsoletePaths', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@ -50,8 +50,8 @@ class PlatformMediaStoreService implements MediaStoreService {
return _streamChannel.receiveBroadcastStream(<String, dynamic>{
'knownEntries': knownEntries,
}).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) {
reportService.recordChannelError('getEntries', e);
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract class MetadataService {
@ -18,6 +19,8 @@ abstract class MetadataService {
Future<PanoramaInfo?> getPanoramaInfo(AvesEntry entry);
Future<bool> hasContentResolverProp(String prop);
Future<String?> getContentResolverProp(AvesEntry entry, String prop);
}
@ -35,8 +38,8 @@ class PlatformMetadataService implements MetadataService {
'sizeBytes': entry.sizeBytes,
});
if (result != null) return result as Map;
} on PlatformException catch (e) {
await reportService.recordChannelError('getAllMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -65,8 +68,8 @@ class PlatformMetadataService implements MetadataService {
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
} on PlatformException catch (e) {
await reportService.recordChannelError('getCatalogMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -91,8 +94,8 @@ class PlatformMetadataService implements MetadataService {
'sizeBytes': entry.sizeBytes,
}) as Map;
return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) {
await reportService.recordChannelError('getOverlayMetadata', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -113,8 +116,8 @@ class PlatformMetadataService implements MetadataService {
imagePage['rotationDegrees'] = entry.rotationDegrees;
}
return MultiPageInfo.fromPageMaps(entry, pageMaps);
} on PlatformException catch (e) {
await reportService.recordChannelError('getMultiPageInfo', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -131,12 +134,31 @@ class PlatformMetadataService implements MetadataService {
'sizeBytes': entry.sizeBytes,
}) as Map;
return PanoramaInfo.fromMap(result);
} on PlatformException catch (e) {
await reportService.recordChannelError('PanoramaInfo', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
final Map<String, bool> _contentResolverProps = {};
@override
Future<bool> hasContentResolverProp(String prop) async {
var exists = _contentResolverProps[prop];
if (exists != null) return SynchronousFuture(exists);
try {
exists = await platform.invokeMethod('hasContentResolverProp', <String, dynamic>{
'prop': prop,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
exists ??= false;
_contentResolverProps[prop] = exists;
return exists;
}
@override
Future<String?> getContentResolverProp(AvesEntry entry, String prop) async {
try {
@ -145,8 +167,8 @@ class PlatformMetadataService implements MetadataService {
'uri': entry.uri,
'prop': prop,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('getContentResolverProp', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}

View file

@ -1,6 +1,5 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract class ReportService {
bool get isCollectionEnabled;
@ -16,10 +15,6 @@ abstract class ReportService {
Future<void> recordError(dynamic exception, StackTrace? stack);
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails);
Future<void> recordChannelError(String method, PlatformException e) {
return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null);
}
}
class CrashlyticsReportService extends ReportService {

View file

@ -47,8 +47,8 @@ class PlatformStorageService implements StorageService {
try {
final result = await platform.invokeMethod('getStorageVolumes');
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
} on PlatformException catch (e) {
await reportService.recordChannelError('getStorageVolumes', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -60,8 +60,8 @@ class PlatformStorageService implements StorageService {
'path': volume.path,
});
return result as int?;
} on PlatformException catch (e) {
await reportService.recordChannelError('getFreeSpace', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -71,8 +71,8 @@ class PlatformStorageService implements StorageService {
try {
final result = await platform.invokeMethod('getGrantedDirectories');
return (result as List).cast<String>();
} on PlatformException catch (e) {
await reportService.recordChannelError('getGrantedDirectories', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return [];
}
@ -83,8 +83,8 @@ class PlatformStorageService implements StorageService {
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
'path': path,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('revokeDirectoryAccess', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return;
}
@ -98,8 +98,8 @@ class PlatformStorageService implements StorageService {
if (result != null) {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
}
} on PlatformException catch (e) {
await reportService.recordChannelError('getInaccessibleDirectories', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -111,8 +111,8 @@ class PlatformStorageService implements StorageService {
if (result != null) {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
}
} on PlatformException catch (e) {
await reportService.recordChannelError('getRestrictedDirectories', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -134,8 +134,8 @@ class PlatformStorageService implements StorageService {
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
await reportService.recordChannelError('requestVolumeAccess', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@ -148,8 +148,8 @@ class PlatformStorageService implements StorageService {
'dirPaths': dirPaths.toList(),
});
if (result != null) return result as int;
} on PlatformException catch (e) {
await reportService.recordChannelError('deleteEmptyDirectories', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return 0;
}
@ -164,8 +164,8 @@ class PlatformStorageService implements StorageService {
'mimeType': mimeType,
});
if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e) {
await reportService.recordChannelError('scanFile', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}
@ -188,8 +188,8 @@ class PlatformStorageService implements StorageService {
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
await reportService.recordChannelError('createFile', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@ -215,8 +215,8 @@ class PlatformStorageService implements StorageService {
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
await reportService.recordChannelError('openFile', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return Uint8List(0);
}
@ -236,8 +236,8 @@ class PlatformStorageService implements StorageService {
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e) {
await reportService.recordChannelError('selectDirectory', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}

View file

@ -12,8 +12,8 @@ class PlatformTimeService implements TimeService {
Future<String?> getDefaultTimeZone() async {
try {
return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) {
await reportService.recordChannelError('getDefaultTimeZone', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return null;
}

View file

@ -9,8 +9,8 @@ class ViewerService {
// returns nullable map with 'action' and possibly 'uri' 'mimeType'
final result = await platform.invokeMethod('getIntentData');
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) {
await reportService.recordChannelError('getIntentData', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@ -20,8 +20,8 @@ class ViewerService {
await platform.invokeMethod('pick', <String, dynamic>{
'uri': uri,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('pick', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -23,8 +23,8 @@ class PlatformWindowService implements WindowService {
await platform.invokeMethod('keepScreenOn', <String, dynamic>{
'on': on,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('keepScreenOn', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@ -33,8 +33,8 @@ class PlatformWindowService implements WindowService {
try {
final result = await platform.invokeMethod('isRotationLocked');
if (result != null) return result as bool;
} on PlatformException catch (e) {
await reportService.recordChannelError('isRotationLocked', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@ -61,8 +61,8 @@ class PlatformWindowService implements WindowService {
await platform.invokeMethod('requestOrientation', <String, dynamic>{
'orientation': orientationCode,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('requestOrientation', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
@ -71,8 +71,8 @@ class PlatformWindowService implements WindowService {
try {
final result = await platform.invokeMethod('canSetCutoutMode');
if (result != null) return result as bool;
} on PlatformException catch (e) {
await reportService.recordChannelError('canSetCutoutMode', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@ -83,8 +83,8 @@ class PlatformWindowService implements WindowService {
await platform.invokeMethod('setCutoutMode', <String, dynamic>{
'use': use,
});
} on PlatformException catch (e) {
await reportService.recordChannelError('setCutoutMode', e);
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -26,6 +26,16 @@ class Constants {
static final pointNemo = LatLng(-48.876667, -123.393333);
static final wonders = [
LatLng(29.979167, 31.134167),
LatLng(36.451000, 28.223615),
LatLng(32.5355, 44.4275),
LatLng(31.213889, 29.885556),
LatLng(37.0379, 27.4241),
LatLng(37.637861, 21.63),
LatLng(37.949722, 27.363889),
];
static const int infoGroupMaxValueLength = 140;
static const List<Dependency> androidDependencies = [
@ -274,6 +284,11 @@ class Constants {
license: 'Apache 2.0',
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
),
Dependency(
name: 'Transparent Image',
license: 'MIT',
sourceUrl: 'https://pub.dev/packages/transparent_image',
),
Dependency(
name: 'Tuple',
license: 'BSD 2-Clause',

View file

@ -123,17 +123,19 @@ class _AvesAppState extends State<AvesApp> {
}
Future<void> _setup() async {
await Firebase.initializeApp().then((app) {
await Firebase.initializeApp().then((app) async {
FlutterError.onError = reportService.recordFlutterError;
final now = DateTime.now();
reportService.setCustomKeys({
'locales': window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
final hasPlayServices = await availability.hasPlayServices;
await reportService.setCustomKeys({
'build_mode': kReleaseMode
? 'release'
: kProfileMode
? 'profile'
: 'debug',
'has_play_services': hasPlayServices,
'locales': window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
});
});
await settings.init();

View file

@ -1,8 +1,8 @@
import 'dart:async';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
@ -12,20 +12,17 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/app_shortcut_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -68,7 +65,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AppShortcutService.canPin();
_registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateHeight());
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
}
@override
@ -87,11 +84,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
void _registerWidget(CollectionAppBar widget) {
widget.collection.filterChangeNotifier.addListener(_updateHeight);
widget.collection.filterChangeNotifier.addListener(_onFilterChanged);
}
void _unregisterWidget(CollectionAppBar widget) {
widget.collection.filterChangeNotifier.removeListener(_updateHeight);
widget.collection.filterChangeNotifier.removeListener(_onFilterChanged);
}
@override
@ -149,7 +146,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget? _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<AvesEntry>, int>(
selector: (context, selection) => selection.selection.length,
selector: (context, selection) => selection.selectedItems.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
@ -178,9 +175,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
),
if (isSelecting)
...EntryActions.selection.map((action) => Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.selection.isEmpty,
selector: (context, selection) => selection.selectedItems.isEmpty,
builder: (context, isEmpty, child) => IconButton(
icon: Icon(action.getIcon()),
icon: action.getIcon() ?? const SizedBox(),
onPressed: isEmpty ? null : () => _actionDelegate.onEntryActionSelected(context, action),
tooltip: action.getText(context),
),
@ -189,87 +186,83 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
future: _canAddShortcutsLoader,
builder: (context, snapshot) {
final canAddShortcuts = snapshot.data ?? false;
return PopupMenuButton<CollectionAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final selection = context.read<Selection<AvesEntry>>();
final isNotEmpty = !collection.isEmpty;
final hasSelection = selection.selection.isNotEmpty;
return [
PopupMenuItem(
key: const Key('menu-sort'),
value: CollectionAction.sort,
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
),
if (collection.sortFactor == EntrySortFactor.date)
PopupMenuItem(
key: const Key('menu-group'),
value: CollectionAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
return MenuIconTheme(
child: PopupMenuButton<EntrySetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final groupable = collection.sortFactor == EntrySortFactor.date;
final selection = context.read<Selection<AvesEntry>>();
final isSelecting = selection.isSelecting;
final selectedItems = selection.selectedItems;
final hasSelection = selectedItems.isNotEmpty;
final hasItems = !collection.isEmpty;
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
return [
_toMenuItem(
EntrySetAction.sort,
key: const Key('menu-sort'),
),
if (!selection.isSelecting && appMode == AppMode.main) ...[
PopupMenuItem(
value: CollectionAction.select,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
),
PopupMenuItem(
value: CollectionAction.map,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map),
),
PopupMenuItem(
value: CollectionAction.stats,
enabled: isNotEmpty,
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
),
if (canAddShortcuts)
PopupMenuItem(
value: CollectionAction.addShortcut,
child: MenuRow(text: context.l10n.collectionActionAddShortcut, icon: AIcons.addShortcut),
if (groupable)
_toMenuItem(
EntrySetAction.group,
key: const Key('menu-group'),
),
],
if (selection.isSelecting) ...[
const PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.copy,
enabled: hasSelection,
child: MenuRow(text: context.l10n.collectionActionCopy),
),
PopupMenuItem(
value: CollectionAction.move,
enabled: hasSelection,
child: MenuRow(text: context.l10n.collectionActionMove),
),
PopupMenuItem(
value: CollectionAction.refreshMetadata,
enabled: hasSelection,
child: MenuRow(text: context.l10n.collectionActionRefreshMetadata),
),
const PopupMenuDivider(),
PopupMenuItem(
value: CollectionAction.selectAll,
enabled: selection.selection.length < collection.entryCount,
child: MenuRow(text: context.l10n.collectionActionSelectAll),
),
PopupMenuItem(
value: CollectionAction.selectNone,
enabled: hasSelection,
child: MenuRow(text: context.l10n.collectionActionSelectNone),
),
]
];
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
},
if (appMode == AppMode.main) ...[
if (!isSelecting)
_toMenuItem(
EntrySetAction.select,
enabled: hasItems,
),
const PopupMenuDivider(),
if (isSelecting)
...[
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.refreshMetadata,
].map((v) => _toMenuItem(v, enabled: hasSelection)),
...[
EntrySetAction.map,
EntrySetAction.stats,
].map((v) => _toMenuItem(v, enabled: otherViewEnabled)),
if (!isSelecting && canAddShortcuts) ...[
const PopupMenuDivider(),
_toMenuItem(EntrySetAction.addShortcut),
],
],
if (isSelecting) ...[
const PopupMenuDivider(),
_toMenuItem(
EntrySetAction.selectAll,
enabled: selectedItems.length < collection.entryCount,
),
_toMenuItem(
EntrySetAction.selectNone,
enabled: hasSelection,
),
]
];
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onCollectionActionSelected(action));
},
),
);
},
),
];
}
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {Key? key, bool enabled = true}) {
return PopupMenuItem(
key: key,
value: action,
enabled: enabled,
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
);
}
void _onActivityChange() {
if (context.read<Selection<AvesEntry>>().isSelecting) {
_browseToSelectAnimation.forward();
@ -278,36 +271,41 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
}
void _updateHeight() {
void _onFilterChanged() {
widget.appBarHeightNotifier.value = kToolbarHeight + (hasFilters ? FilterBar.preferredHeight : 0);
if (hasFilters) {
final filters = collection.filters;
final selection = context.read<Selection<AvesEntry>>();
if (selection.isSelecting) {
final toRemove = selection.selectedItems.where((entry) => !filters.every((f) => f.test(entry))).toSet();
selection.removeFromSelection(toRemove);
}
}
}
Future<void> _onCollectionActionSelected(CollectionAction action) async {
Future<void> _onCollectionActionSelected(EntrySetAction action) async {
switch (action) {
case CollectionAction.copy:
case CollectionAction.move:
case CollectionAction.refreshMetadata:
case EntrySetAction.copy:
case EntrySetAction.move:
case EntrySetAction.refreshMetadata:
case EntrySetAction.map:
case EntrySetAction.stats:
_actionDelegate.onCollectionActionSelected(context, action);
break;
case CollectionAction.select:
case EntrySetAction.select:
context.read<Selection<AvesEntry>>().select();
break;
case CollectionAction.selectAll:
case EntrySetAction.selectAll:
context.read<Selection<AvesEntry>>().addToSelection(collection.sortedEntries);
break;
case CollectionAction.selectNone:
case EntrySetAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection();
break;
case CollectionAction.map:
_goToMap();
break;
case CollectionAction.stats:
_goToStats();
break;
case CollectionAction.addShortcut:
case EntrySetAction.addShortcut:
unawaited(_showShortcutDialog(context));
break;
case CollectionAction.group:
case EntrySetAction.group:
final value = await showDialog<EntryGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
@ -327,7 +325,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
settings.collectionSectionFactor = value;
}
break;
case CollectionAction.sort:
case EntrySetAction.sort:
final value = await showDialog<EntrySortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<EntrySortFactor>(
@ -385,30 +383,4 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
),
);
}
void _goToMap() {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
parentCollection: collection,
),
),
);
}
void _goToStats() {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
parentCollection: collection,
),
),
);
}
}

View file

@ -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';

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
@ -22,6 +22,8 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/stats/stats_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -41,24 +43,30 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}
}
void onCollectionActionSelected(BuildContext context, CollectionAction action) {
void onCollectionActionSelected(BuildContext context, EntrySetAction action) {
switch (action) {
case CollectionAction.copy:
case EntrySetAction.copy:
_moveSelection(context, moveType: MoveType.copy);
break;
case CollectionAction.move:
case EntrySetAction.move:
_moveSelection(context, moveType: MoveType.move);
break;
case CollectionAction.refreshMetadata:
case EntrySetAction.refreshMetadata:
_refreshMetadata(context);
break;
case EntrySetAction.map:
_goToMap(context);
break;
case EntrySetAction.stats:
_goToStats(context);
break;
default:
break;
}
}
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
void _share(BuildContext context) {
@ -242,4 +250,37 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
},
);
}
void _goToMap(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : context.read<CollectionLens>().sortedEntries;
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
entries: entries.where((entry) => entry.hasGps).toList(),
),
),
);
}
void _goToStats(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>();
final collection = context.read<CollectionLens>();
final entries = selection.isSelecting ? _getExpandedSelectedItems(selection) : collection.sortedEntries.toSet();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
entries: entries,
source: collection.source,
parentCollection: collection,
),
),
);
}
}

View file

@ -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';

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class MenuRow extends StatelessWidget {
final String text;
final Widget? icon;
const MenuRow({
Key? key,
required this.text,
this.icon,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: icon,
),
Expanded(child: Text(text)),
],
);
}
}
// scale icons according to text scale
class MenuIconTheme extends StatelessWidget {
final Widget child;
const MenuIconTheme({
Key? key,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
return IconTheme(
data: iconTheme.copyWith(
size: iconTheme.size! * MediaQuery.textScaleFactorOf(context),
),
child: child,
);
}
}

View file

@ -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)),
],
);
}
}

View file

@ -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(),
);

View file

@ -117,17 +117,25 @@ class MultiPageIcon extends StatelessWidget {
if (entry.isMotionPhoto) {
icon = AIcons.motionPhoto;
} else {
if(entry.isBurst) {
if (entry.isBurst) {
text = '${entry.burstEntries?.length}';
}
icon = AIcons.multiPage;
}
return OverlayIcon(
final gridTheme = context.watch<GridThemeData>();
final child = OverlayIcon(
icon: icon,
size: context.select<GridThemeData, double>((t) => t.iconSize),
size: gridTheme.iconSize,
iconScale: .8,
text: text,
);
return DefaultTextStyle(
style: TextStyle(
color: Colors.grey.shade200,
fontSize: gridTheme.fontSize,
),
child: child,
);
}
}

View file

@ -8,6 +8,8 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/map/compass.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
@ -16,19 +18,23 @@ import 'package:flutter/scheduler.dart';
import 'package:latlong2/latlong.dart';
class MapButtonPanel extends StatelessWidget {
final LatLng latLng;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final Future<void> Function(double amount)? zoomBy;
final VoidCallback? resetRotation;
static const double padding = 4;
const MapButtonPanel({
Key? key,
required this.latLng,
required this.boundsNotifier,
this.zoomBy,
this.resetRotation,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
final iconSize = Size.square(iconTheme.size!);
return Positioned.fill(
child: Align(
alignment: AlignmentDirectional.centerEnd,
@ -38,53 +44,96 @@ class MapButtonPanel extends StatelessWidget {
data: TooltipTheme.of(context).copyWith(
preferBelow: false,
),
child: Column(
mainAxisSize: MainAxisSize.min,
child: Stack(
children: [
MapOverlayButton(
icon: AIcons.openOutside,
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
if (!success) showNoMatchingAppDialog(context);
}),
tooltip: context.l10n.entryActionOpenMap,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.layers,
onPressed: () async {
final hasPlayServices = await availability.hasPlayServices;
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
context: context,
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.viewerInfoMapStyleTitle,
if (resetRotation != null)
Positioned(
left: 0,
child: ValueListenableBuilder<ZoomedBounds>(
valueListenable: boundsNotifier,
builder: (context, bounds, child) {
final degrees = bounds.rotation;
return AnimatedOpacity(
opacity: degrees == 0 ? 0 : 1,
duration: Durations.viewerOverlayAnimation,
child: MapOverlayButton(
icon: Transform(
origin: iconSize.center(Offset.zero),
transform: Matrix4.rotationZ(degToRadian(degrees)),
child: CustomPaint(
painter: CompassPainter(
color: iconTheme.color!,
),
size: iconSize,
),
),
onPressed: () => resetRotation?.call(),
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
),
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style;
}
},
tooltip: context.l10n.viewerInfoMapStyleTooltip,
),
),
Positioned(
right: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.openOutside),
onPressed: () => AndroidAppService.openMap(boundsNotifier.value.center).then((success) {
if (!success) showNoMatchingAppDialog(context);
}),
tooltip: context.l10n.entryActionOpenMap,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: const Icon(AIcons.layers),
onPressed: () async {
final hasPlayServices = await availability.hasPlayServices;
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
final preferredStyle = settings.infoMapStyle;
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
final style = await showDialog<EntryMapStyle>(
context: context,
builder: (context) {
return AvesSelectionDialog<EntryMapStyle>(
initialValue: initialStyle,
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
title: context.l10n.viewerInfoMapStyleTitle,
);
},
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (style != null && style != settings.infoMapStyle) {
settings.infoMapStyle = style;
}
},
tooltip: context.l10n.viewerInfoMapStyleTooltip,
),
],
),
),
const Spacer(),
MapOverlayButton(
icon: AIcons.zoomIn,
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: AIcons.zoomOut,
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
Positioned(
right: 0,
bottom: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
MapOverlayButton(
icon: const Icon(AIcons.zoomIn),
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
),
const SizedBox(height: padding),
MapOverlayButton(
icon: const Icon(AIcons.zoomOut),
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
),
],
),
),
],
),
@ -96,7 +145,7 @@ class MapButtonPanel extends StatelessWidget {
}
class MapOverlayButton extends StatelessWidget {
final IconData icon;
final Widget icon;
final String tooltip;
final VoidCallback? onPressed;
@ -123,7 +172,7 @@ class MapOverlayButton extends StatelessWidget {
child: IconButton(
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: Icon(icon),
icon: icon,
onPressed: onPressed,
tooltip: tooltip,
),

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class CompassPainter extends CustomPainter {
final Color color;
const CompassPainter({
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final base = size.width * .3;
final height = size.height * .4;
final northTriangle = Path()
..moveTo(center.dx - base / 2, center.dy)
..lineTo(center.dx, center.dy - height)
..lineTo(center.dx + base / 2, center.dy)
..close();
final southTriangle = Path()
..moveTo(center.dx - base / 2, center.dy)
..lineTo(center.dx + base / 2, center.dy)
..lineTo(center.dx, center.dy + height)
..close();
final fillPaint = Paint()
..style = PaintingStyle.fill
..color = color.withOpacity(.6);
final strokePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.7
..strokeJoin = StrokeJoin.round
..color = color;
canvas.drawPath(northTriangle, fillPaint);
canvas.drawPath(northTriangle, strokePaint);
canvas.drawPath(southTriangle, strokePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View file

@ -0,0 +1,23 @@
import 'dart:async';
import 'package:latlong2/latlong.dart';
class AvesMapController {
final StreamController _streamController = StreamController.broadcast();
Stream<dynamic> get _events => _streamController.stream;
Stream<MapControllerMoveEvent> get moveEvents => _events.where((event) => event is MapControllerMoveEvent).cast<MapControllerMoveEvent>();
void dispose() {
_streamController.close();
}
void moveTo(LatLng latLng) => _streamController.add(MapControllerMoveEvent(latLng));
}
class MapControllerMoveEvent {
final LatLng latLng;
MapControllerMoveEvent(this.latLng);
}

View file

@ -1,17 +1,23 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/map/attribution.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/controller.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/google/map.dart';
import 'package:aves/widgets/common/map/leaflet/map.dart';
import 'package:aves/widgets/common/map/marker.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/foundation.dart';
@ -19,22 +25,26 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GeoMap extends StatefulWidget {
final AvesMapController? controller;
final List<AvesEntry> entries;
final bool interactive;
final double? mapHeight;
final ValueNotifier<bool> isAnimatingNotifier;
final UserZoomChangeCallback? onUserZoomChange;
final MarkerTapCallback? onMarkerTap;
static const markerImageExtent = 48.0;
static const pointerSize = Size(8, 6);
const GeoMap({
Key? key,
this.controller,
required this.entries,
required this.interactive,
this.mapHeight,
required this.isAnimatingNotifier,
this.onUserZoomChange,
this.onMarkerTap,
}) : super(key: key);
@override
@ -47,7 +57,9 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
// it is especially severe the first time, but still significant afterwards
// so we prevent loading it while scrolling or animating
bool _googleMapsLoaded = false;
late ValueNotifier<ZoomedBounds> boundsNotifier;
late final ValueNotifier<ZoomedBounds> _boundsNotifier;
late final Fluster<GeoEntry> _defaultMarkerCluster;
Fluster<GeoEntry>? _slowMarkerCluster;
List<AvesEntry> get entries => widget.entries;
@ -58,36 +70,49 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
@override
void initState() {
super.initState();
boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints(
points: entries.map((v) => v.latLng!).toSet(),
final points = entries.map((v) => v.latLng!).toSet();
_boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints(
points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]},
collocationZoom: settings.infoMapZoom,
));
_defaultMarkerCluster = _buildFluster();
}
@override
Widget build(BuildContext context) {
final markers = entries.map((entry) {
var latLng = entry.latLng!;
return GeoEntry(
entry: entry,
latitude: latLng.latitude,
longitude: latLng.longitude,
markerId: entry.uri,
);
}).toList();
final markerCluster = Fluster<GeoEntry>(
// we keep clustering on the whole range of zooms (including the maximum)
// to avoid collocated entries overlapping
minZoom: 0,
maxZoom: 22,
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
radius: 240,
extent: 2 << 9,
nodeSize: 64,
points: markers,
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
);
void _onMarkerTap(GeoEntry geoEntry) {
final onTap = widget.onMarkerTap;
if (onTap == null) return;
final clusterId = geoEntry.clusterId;
Set<AvesEntry> getClusterEntries() {
if (clusterId == null) {
return {geoEntry.entry!};
}
var points = _defaultMarkerCluster.points(clusterId);
if (points.length != geoEntry.pointsSize) {
// `Fluster.points()` method does not always return all the points contained in a cluster
// the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`)
_slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length));
points = _slowMarkerCluster!.points(clusterId);
assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry');
}
return points.map((geoEntry) => geoEntry.entry!).toSet();
}
AvesEntry? markerEntry;
if (clusterId != null) {
final uri = geoEntry.childMarkerId;
markerEntry = entries.firstWhereOrNull((v) => v.uri == uri);
} else {
markerEntry = geoEntry.entry;
}
if (markerEntry != null) {
onTap(markerEntry, getClusterEntries);
}
}
return FutureBuilder<bool>(
future: availability.isConnected,
@ -98,7 +123,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
builder: (context, mapStyle, child) {
final isGoogleMaps = mapStyle.isGoogleMaps;
final progressive = !isGoogleMaps;
Widget _buildMarker(MarkerKey key) => ImageMarker(
Widget _buildMarkerWidget(MarkerKey key) => ImageMarker(
key: key,
entry: key.entry,
count: key.count,
@ -109,26 +134,32 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
Widget child = isGoogleMaps
? EntryGoogleMap(
boundsNotifier: boundsNotifier,
controller: widget.controller,
boundsNotifier: _boundsNotifier,
interactive: interactive,
minZoom: 0,
maxZoom: 20,
style: mapStyle,
markerBuilder: _buildMarker,
markerCluster: markerCluster,
markerEntries: entries,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
onUserZoomChange: widget.onUserZoomChange,
onMarkerTap: _onMarkerTap,
)
: EntryLeafletMap(
boundsNotifier: boundsNotifier,
controller: widget.controller,
boundsNotifier: _boundsNotifier,
interactive: interactive,
minZoom: 2,
maxZoom: 16,
style: mapStyle,
markerBuilder: _buildMarker,
markerCluster: markerCluster,
markerEntries: entries,
markerClusterBuilder: _buildMarkerClusters,
markerWidgetBuilder: _buildMarkerWidget,
markerSize: Size(
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2,
GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height,
),
onUserZoomChange: widget.onUserZoomChange,
onMarkerTap: _onMarkerTap,
);
child = Column(
@ -161,7 +192,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
interactive: interactive,
),
MapButtonPanel(
latLng: boundsNotifier.value.center,
boundsNotifier: _boundsNotifier,
),
],
);
@ -185,6 +216,46 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
},
);
}
Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {
final markers = entries.map((entry) {
final latLng = entry.latLng!;
return GeoEntry(
entry: entry,
latitude: latLng.latitude,
longitude: latLng.longitude,
markerId: entry.uri,
);
}).toList();
return Fluster<GeoEntry>(
// we keep clustering on the whole range of zooms (including the maximum)
// to avoid collocated entries overlapping
minZoom: 0,
maxZoom: 22,
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
radius: 240,
extent: 2 << 9,
// node size: 64 by default, higher means faster indexing but slower search
nodeSize: nodeSize,
points: markers,
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
);
}
Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
final bounds = _boundsNotifier.value;
final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round());
return Map.fromEntries(geoEntries.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = entries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
}
}
@immutable
@ -198,5 +269,7 @@ class MarkerKey extends LocalKey with EquatableMixin {
const MarkerKey(this.entry, this.count);
}
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
typedef MarkerClusterBuilder = Map<MarkerKey, GeoEntry> Function();
typedef MarkerWidgetBuilder = Widget Function(MarkerKey key);
typedef UserZoomChangeCallback = void Function(double zoom);
typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set<AvesEntry> Function() getClusterEntries);

View file

@ -1,40 +1,43 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_images.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/controller.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
import 'package:aves/widgets/common/map/google/marker_generator.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:collection/collection.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:latlong2/latlong.dart' as ll;
class EntryGoogleMap extends StatefulWidget {
final AvesMapController? controller;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final bool interactive;
final double? minZoom, maxZoom;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> markerEntries;
final MarkerClusterBuilder markerClusterBuilder;
final MarkerWidgetBuilder markerWidgetBuilder;
final UserZoomChangeCallback? onUserZoomChange;
final void Function(GeoEntry geoEntry)? onMarkerTap;
const EntryGoogleMap({
Key? key,
this.controller,
required this.boundsNotifier,
required this.interactive,
this.minZoom,
this.maxZoom,
required this.style,
required this.markerBuilder,
required this.markerCluster,
required this.markerEntries,
required this.markerClusterBuilder,
required this.markerWidgetBuilder,
this.onUserZoomChange,
this.onMarkerTap,
}) : super(key: key);
@override
@ -42,7 +45,9 @@ class EntryGoogleMap extends StatefulWidget {
}
class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObserver {
GoogleMapController? _controller;
GoogleMapController? _googleMapController;
final List<StreamSubscription> _subscriptions = [];
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
@ -50,28 +55,45 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
ZoomedBounds get bounds => boundsNotifier.value;
bool get interactive => widget.interactive;
static const uninitializedLatLng = LatLng(0, 0);
@override
void initState() {
super.initState();
WidgetsBinding.instance!.addObserver(this);
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant EntryGoogleMap oldWidget) {
super.didUpdateWidget(oldWidget);
const eq = DeepCollectionEquality();
if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) {
_markerBitmaps.clear();
}
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_controller?.dispose();
_unregisterWidget(widget);
_googleMapController?.dispose();
WidgetsBinding.instance!.removeObserver(this);
super.dispose();
}
void _registerWidget(EntryGoogleMap widget) {
final avesMapController = widget.controller;
if (avesMapController != null) {
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
}
}
void _unregisterWidget(EntryGoogleMap widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
@ -82,64 +104,51 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
case AppLifecycleState.resumed:
// workaround for blank Google Maps when resuming app
// cf https://github.com/flutter/flutter/issues/40284
_controller?.setMapStyle(null);
_googleMapController?.setMapStyle(null);
break;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MarkerGeneratorWidget<MarkerKey>(
markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(),
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
onRendered: (key, bitmap) {
_markerBitmaps[key] = bitmap;
_markerBitmapChangeNotifier.notifyListeners();
},
),
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
return Stack(
children: [
MarkerGeneratorWidget<MarkerKey>(
markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(),
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
onRendered: (key, bitmap) {
_markerBitmaps[key] = bitmap;
_markerBitmapChangeNotifier.notifyListeners();
},
),
MapDecorator(
interactive: interactive,
child: _buildMap(),
),
MapButtonPanel(
boundsNotifier: boundsNotifier,
zoomBy: _zoomBy,
resetRotation: interactive ? _resetRotation : null,
),
],
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
Widget _buildMap() {
return AnimatedBuilder(
animation: _markerBitmapChangeNotifier,
builder: (context, child) {
final markers = <Marker>{};
clusterByMarkerKey.forEach((markerKey, cluster) {
_geoEntryByMarkerKey.forEach((markerKey, geoEntry) {
final bytes = _markerBitmaps[markerKey];
if (bytes != null) {
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
final point = LatLng(geoEntry.latitude!, geoEntry.longitude!);
markers.add(Marker(
markerId: MarkerId(cluster.markerId!),
markerId: MarkerId(geoEntry.markerId!),
consumeTapEvents: true,
icon: BitmapDescriptor.fromBytes(bytes),
position: latLng,
position: point,
onTap: () => widget.onMarkerTap?.call(geoEntry),
));
}
});
@ -150,17 +159,18 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom,
),
onMapCreated: (controller) {
_controller = controller;
controller.getZoomLevel().then(_updateVisibleRegion);
onMapCreated: (controller) async {
_googleMapController = controller;
final zoom = await controller.getZoomLevel();
await _updateVisibleRegion(zoom: zoom, rotation: 0);
setState(() {});
},
// TODO TLAD [map] add common compass button for both google/leaflet
// compass disabled to use provider agnostic controls
compassEnabled: false,
mapToolbarEnabled: false,
mapType: _toMapType(widget.style),
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
rotateGesturesEnabled: false,
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
rotateGesturesEnabled: true,
scrollGesturesEnabled: interactive,
// zoom controls disabled to use provider agnostic controls
zoomControlsEnabled: false,
@ -172,14 +182,22 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
myLocationEnabled: false,
myLocationButtonEnabled: false,
markers: markers,
onCameraMove: (position) => _updateVisibleRegion(position.zoom),
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
onCameraIdle: _updateClusters,
);
},
);
}
Future<void> _updateVisibleRegion(double zoom) async {
final bounds = await _controller?.getVisibleRegion();
void _updateClusters() {
if (!mounted) return;
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
}
Future<void> _updateVisibleRegion({required double zoom, required double rotation}) async {
if (!mounted) return;
final bounds = await _googleMapController?.getVisibleRegion();
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
boundsNotifier.value = ZoomedBounds(
west: bounds.southwest.longitude,
@ -187,25 +205,42 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
east: bounds.northeast.longitude,
north: bounds.northeast.latitude,
zoom: zoom,
rotation: rotation,
);
} else {
// the visible region is sometimes uninitialized when queried right after creation,
// so we query it again next frame
WidgetsBinding.instance!.addPostFrameCallback((_) {
if (!mounted) return;
_updateVisibleRegion(zoom);
_updateVisibleRegion(zoom: zoom, rotation: rotation);
});
}
}
Future<void> _resetRotation() async {
final controller = _googleMapController;
if (controller == null) return;
await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom,
)));
}
Future<void> _zoomBy(double amount) async {
final controller = _controller;
final controller = _googleMapController;
if (controller == null) return;
widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount);
await controller.animateCamera(CameraUpdate.zoomBy(amount));
}
Future<void> _moveTo(LatLng point) async {
final controller = _googleMapController;
if (controller == null) return;
await controller.animateCamera(CameraUpdate.newLatLng(point));
}
// `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package
LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude);

View file

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/map/buttons.dart';
import 'package:aves/widgets/common/map/controller.dart';
import 'package:aves/widgets/common/map/decorator.dart';
import 'package:aves/widgets/common/map/geo_entry.dart';
import 'package:aves/widgets/common/map/geo_map.dart';
@ -10,31 +12,35 @@ import 'package:aves/widgets/common/map/latlng_tween.dart';
import 'package:aves/widgets/common/map/leaflet/scale_layer.dart';
import 'package:aves/widgets/common/map/leaflet/tile_layers.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:fluster/fluster.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
class EntryLeafletMap extends StatefulWidget {
final AvesMapController? controller;
final ValueNotifier<ZoomedBounds> boundsNotifier;
final bool interactive;
final double minZoom, maxZoom;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> markerEntries;
final MarkerClusterBuilder markerClusterBuilder;
final MarkerWidgetBuilder markerWidgetBuilder;
final Size markerSize;
final UserZoomChangeCallback? onUserZoomChange;
final void Function(GeoEntry geoEntry)? onMarkerTap;
const EntryLeafletMap({
Key? key,
this.controller,
required this.boundsNotifier,
required this.interactive,
this.minZoom = 0,
this.maxZoom = 22,
required this.style,
required this.markerBuilder,
required this.markerCluster,
required this.markerEntries,
required this.markerClusterBuilder,
required this.markerWidgetBuilder,
required this.markerSize,
this.onUserZoomChange,
this.onMarkerTap,
}) : super(key: key);
@override
@ -42,81 +48,84 @@ class EntryLeafletMap extends StatefulWidget {
}
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
final MapController _mapController = MapController();
final MapController _leafletMapController = MapController();
final List<StreamSubscription> _subscriptions = [];
Map<MarkerKey, GeoEntry> _geoEntryByMarkerKey = {};
final Debouncer _debouncer = Debouncer(delay: Durations.mapIdleDebounceDelay);
ValueNotifier<ZoomedBounds> get boundsNotifier => widget.boundsNotifier;
ZoomedBounds get bounds => boundsNotifier.value;
bool get interactive => widget.interactive;
// duration should match the uncustomizable Google Maps duration
static const _cameraAnimationDuration = Duration(milliseconds: 400);
static const _zoomMin = 1.0;
// TODO TLAD [map] also limit zoom on pinch-to-zoom gesture
static const _zoomMax = 16.0;
// TODO TLAD [map] allow rotation when leaflet scale layer is fixed
static const interactiveFlags = InteractiveFlag.all & ~InteractiveFlag.rotate;
static const _cameraAnimationDuration = Duration(milliseconds: 600);
@override
void initState() {
super.initState();
_subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion()));
_registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion());
}
@override
void didUpdateWidget(covariant EntryLeafletMap oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(EntryLeafletMap widget) {
final avesMapController = widget.controller;
if (avesMapController != null) {
_subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng)));
}
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
boundsNotifier.addListener(_onBoundsChange);
}
void _unregisterWidget(EntryLeafletMap widget) {
boundsNotifier.removeListener(_onBoundsChange);
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
return Stack(
children: [
MapDecorator(
interactive: interactive,
child: _buildMap(),
),
MapButtonPanel(
boundsNotifier: boundsNotifier,
zoomBy: _zoomBy,
resetRotation: interactive ? _resetRotation : null,
),
],
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
Widget _buildMap() {
final markerSize = widget.markerSize;
final markers = clusterByMarkerKey.entries.map((kv) {
final markers = _geoEntryByMarkerKey.entries.map((kv) {
final markerKey = kv.key;
final cluster = kv.value;
final latLng = LatLng(cluster.latitude!, cluster.longitude!);
final geoEntry = kv.value;
final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!);
return Marker(
point: latLng,
builder: (context) => GestureDetector(
onTap: () => _moveTo(latLng),
child: widget.markerBuilder(markerKey),
onTap: () => widget.onMarkerTap?.call(geoEntry),
child: widget.markerWidgetBuilder(markerKey),
),
width: markerSize.width,
height: markerSize.height,
@ -128,14 +137,19 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
options: MapOptions(
center: bounds.center,
zoom: bounds.zoom,
interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none,
minZoom: widget.minZoom,
maxZoom: widget.maxZoom,
interactiveFlags: widget.interactive ? InteractiveFlag.all : InteractiveFlag.none,
controller: _leafletMapController,
),
mapController: _mapController,
children: [
_buildMapLayer(),
mapController: _leafletMapController,
nonRotatedChildren: [
ScaleLayerWidget(
options: ScaleLayerOptions(),
),
],
children: [
_buildMapLayer(),
MarkerLayerWidget(
options: MarkerLayerOptions(
markers: markers,
@ -160,30 +174,43 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
}
}
void _onBoundsChange() => _debouncer(_updateClusters);
void _updateClusters() {
if (!mounted) return;
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
}
void _updateVisibleRegion() {
final bounds = _mapController.bounds;
final bounds = _leafletMapController.bounds;
if (bounds != null) {
boundsNotifier.value = ZoomedBounds(
west: bounds.west,
south: bounds.south,
east: bounds.east,
north: bounds.north,
zoom: _mapController.zoom,
zoom: _leafletMapController.zoom,
rotation: _leafletMapController.rotation,
);
}
}
Future<void> _resetRotation() async {
final rotationTween = Tween<double>(begin: _leafletMapController.rotation, end: 0);
await _animateCamera((animation) => _leafletMapController.rotate(rotationTween.evaluate(animation)));
}
Future<void> _zoomBy(double amount) async {
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
final endZoom = (_leafletMapController.zoom + amount).clamp(widget.minZoom, widget.maxZoom);
widget.onUserZoomChange?.call(endZoom);
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
final zoomTween = Tween<double>(begin: _leafletMapController.zoom, end: endZoom);
await _animateCamera((animation) => _leafletMapController.move(_leafletMapController.center, zoomTween.evaluate(animation)));
}
Future<void> _moveTo(LatLng point) async {
final centerTween = LatLngTween(begin: _mapController.center, end: point);
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
final centerTween = LatLngTween(begin: _leafletMapController.center, end: point);
await _animateCamera((animation) => _leafletMapController.move(centerTween.evaluate(animation)!, _leafletMapController.zoom));
}
Future<void> _animateCamera(void Function(Animation<double> animation) animate) async {

View file

@ -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;

View file

@ -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';

View file

@ -6,14 +6,14 @@ import 'package:latlong2/latlong.dart';
@immutable
class ZoomedBounds extends Equatable {
final double west, south, east, north, zoom;
final double west, south, east, north, zoom, rotation;
List<double> get boundingBox => [west, south, east, north];
LatLng get center => LatLng((north + south) / 2, (east + west) / 2);
@override
List<Object?> get props => [west, south, east, north, zoom];
List<Object?> get props => [west, south, east, north, zoom, rotation];
const ZoomedBounds({
required this.west,
@ -21,6 +21,7 @@ class ZoomedBounds extends Equatable {
required this.east,
required this.north,
required this.zoom,
required this.rotation,
});
static const _collocationMaxDeltaThreshold = 360 / (2 << 19);
@ -59,6 +60,7 @@ class ZoomedBounds extends Equatable {
east: east,
north: north,
zoom: zoom,
rotation: 0,
);
}
}

View file

@ -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 {

View file

@ -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';

View file

@ -0,0 +1,177 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/thumbnail/decorated.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ThumbnailScroller extends StatefulWidget {
final double availableWidth;
final int entryCount;
final AvesEntry? Function(int index) entryBuilder;
final ValueNotifier<int?> indexNotifier;
const ThumbnailScroller({
Key? key,
required this.availableWidth,
required this.entryCount,
required this.entryBuilder,
required this.indexNotifier,
}) : super(key: key);
@override
_ThumbnailScrollerState createState() => _ThumbnailScrollerState();
}
class _ThumbnailScrollerState extends State<ThumbnailScroller> {
final _cancellableNotifier = ValueNotifier(true);
late ScrollController _scrollController;
bool _isAnimating = false, _isScrolling = false;
static const double extent = 48;
static const double separatorWidth = 2;
int get entryCount => widget.entryCount;
ValueNotifier<int?> get indexNotifier => widget.indexNotifier;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant ThumbnailScroller oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.indexNotifier != widget.indexNotifier) {
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(ThumbnailScroller widget) {
final scrollOffset = indexToScrollOffset(indexNotifier.value ?? 0);
_scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange);
widget.indexNotifier.addListener(_onIndexChange);
}
void _unregisterWidget(ThumbnailScroller widget) {
_scrollController.removeListener(_onScrollChange);
_scrollController.dispose();
widget.indexNotifier.removeListener(_onIndexChange);
}
@override
Widget build(BuildContext context) {
final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth);
final horizontalMargin = SizedBox(width: marginWidth);
const separator = SizedBox(width: separatorWidth);
return GridTheme(
extent: extent,
showLocation: false,
child: SizedBox(
height: extent,
child: ListView.separated(
scrollDirection: Axis.horizontal,
controller: _scrollController,
// default padding in scroll direction matches `MediaQuery.viewPadding`,
// but we already accommodate for it, so make sure horizontal padding is 0
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
if (index == 0 || index == entryCount + 1) return horizontalMargin;
final page = index - 1;
final pageEntry = widget.entryBuilder(page);
if (pageEntry == null) return const SizedBox();
return Stack(
children: [
GestureDetector(
onTap: () => indexNotifier.value = page,
child: DecoratedThumbnail(
entry: pageEntry,
tileExtent: extent,
// the retrieval task queue can pile up for thumbnails of heavy pages
// (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers)
// so we cancel these requests when possible
cancellableNotifier: _cancellableNotifier,
selectable: false,
highlightable: false,
hero: false,
),
),
IgnorePointer(
child: ValueListenableBuilder<int?>(
valueListenable: indexNotifier,
builder: (context, currentIndex, child) {
return AnimatedContainer(
color: currentIndex == page ? Colors.transparent : Colors.black45,
width: extent,
height: extent,
duration: Durations.thumbnailScrollerShadeAnimation,
);
},
),
)
],
);
},
separatorBuilder: (context, index) => separator,
itemCount: entryCount + 2,
),
),
);
}
Future<void> _goTo(int index) async {
final targetOffset = indexToScrollOffset(index);
final offsetDelta = (targetOffset - _scrollController.offset).abs();
if (offsetDelta > widget.availableWidth * 2) {
_scrollController.jumpTo(targetOffset);
} else {
_isAnimating = true;
await _scrollController.animateTo(
targetOffset,
duration: Durations.thumbnailScrollerScrollAnimation,
curve: Curves.easeOutCubic,
);
_isAnimating = false;
}
}
void _onScrollChange() {
if (!_isAnimating) {
final index = scrollOffsetToIndex(_scrollController.offset);
if (indexNotifier.value != index) {
_isScrolling = true;
indexNotifier.value = index;
}
}
}
void _onIndexChange() {
if (!_isScrolling && !_isAnimating) {
final index = indexNotifier.value;
if (index != null) {
_goTo(index);
}
}
_isScrolling = false;
}
double indexToScrollOffset(int index) => index * (extent + separatorWidth);
int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round();
}

View file

@ -15,7 +15,7 @@ class DebugSettingsSection extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<Settings>(
builder: (context, settings, child) {
String toMultiline(Iterable l) => l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
String toMultiline(Iterable? l) => l != null && l.isNotEmpty ? '\n${l.join('\n')}' : '$l';
return AvesExpansionTile(
title: 'Settings',
children: [
@ -54,6 +54,9 @@ class DebugSettingsSection extends StatelessWidget {
'infoMapZoom': '${settings.infoMapZoom}',
'viewerQuickActions': '${settings.viewerQuickActions}',
'videoQuickActions': '${settings.videoQuickActions}',
'drawerTypeBookmarks': toMultiline(settings.drawerTypeBookmarks),
'drawerAlbumBookmarks': toMultiline(settings.drawerAlbumBookmarks),
'drawerPageBookmarks': toMultiline(settings.drawerPageBookmarks),
'pinnedFilters': toMultiline(settings.pinnedFilters),
'hiddenFilters': toMultiline(settings.hiddenFilters),
'searchHistory': toMultiline(settings.searchHistory),

View file

@ -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';

View file

@ -77,9 +77,9 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
title,
const Spacer(),
IconButton(
onPressed: _isCustom ? _pickEntry : null,
tooltip: 'Change',
icon: const Icon(AIcons.setCover),
onPressed: _isCustom ? _pickEntry : null,
tooltip: context.l10n.changeTooltip,
),
])
: title,

View file

@ -1,38 +0,0 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AlbumTile extends StatelessWidget {
final String album;
const AlbumTile({
Key? key,
required this.album,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
final displayName = source.getAlbumDisplayName(context, album);
return CollectionNavTile(
leading: IconUtils.getAlbumIcon(
context: context,
albumPath: album,
),
title: displayName,
trailing: androidFileUtils.isOnRemovableStorage(album)
? const Icon(
AIcons.removableStorage,
size: 16,
color: Colors.grey,
)
: null,
filter: AlbumFilter(album, displayName),
);
}
}

View file

@ -1,7 +1,5 @@
import 'dart:ui';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
@ -16,8 +14,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_logo.dart';
import 'package:aves/widgets/debug/app_debug_page.dart';
import 'package:aves/widgets/drawer/album_tile.dart';
import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:aves/widgets/drawer/collection_nav_tile.dart';
import 'package:aves/widgets/drawer/page_nav_tile.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
@ -32,6 +30,16 @@ class AppDrawer extends StatefulWidget {
@override
_AppDrawerState createState() => _AppDrawerState();
static List<String> getDefaultAlbums(BuildContext context) {
final source = context.read<CollectionSource>();
final specialAlbums = source.rawAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album);
return [AlbumType.camera, AlbumType.screenshots].contains(type);
}).toList()
..sort(source.compareAlbumsByName);
return specialAlbums;
}
}
class _AppDrawerState extends State<AppDrawer> {
@ -47,19 +55,11 @@ class _AppDrawerState extends State<AppDrawer> {
@override
Widget build(BuildContext context) {
final hiddenFilters = settings.hiddenFilters;
final showVideos = !hiddenFilters.contains(MimeFilter.video);
final showFavourites = !hiddenFilters.contains(FavouriteFilter.instance);
final drawerItems = <Widget>[
_buildHeader(context),
allCollectionTile,
if (showVideos) videoTile,
if (showFavourites) favouriteTile,
_buildSpecialAlbumSection(),
const Divider(),
albumListTile,
countryListTile,
tagListTile,
..._buildTypeLinks(),
_buildAlbumLinks(),
..._buildPageLinks(),
if (!kReleaseMode) ...[
const Divider(),
debugTile,
@ -192,82 +192,77 @@ class _AppDrawerState extends State<AppDrawer> {
);
}
Widget _buildSpecialAlbumSection() {
List<Widget> _buildTypeLinks() {
final hiddenFilters = settings.hiddenFilters;
final typeBookmarks = settings.drawerTypeBookmarks;
return typeBookmarks
.where((filter) => !hiddenFilters.contains(filter))
.map((filter) => CollectionNavTile(
leading: DrawerFilterIcon(filter: filter),
title: DrawerFilterTitle(filter: filter),
filter: filter,
))
.toList();
}
Widget _buildAlbumLinks() {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final specialAlbums = source.rawAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album);
return [AlbumType.camera, AlbumType.screenshots].contains(type);
}).toList()
..sort(source.compareAlbumsByName);
if (specialAlbums.isEmpty) return const SizedBox.shrink();
final albums = settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context);
if (albums.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const Divider(),
...specialAlbums.map((album) => AlbumTile(album: album)),
...albums.map((album) => AlbumNavTile(album: album)),
],
);
});
}
// tiles
List<Widget> _buildPageLinks() {
final pageBookmarks = settings.drawerPageBookmarks;
if (pageBookmarks.isEmpty) return [];
Widget get allCollectionTile => CollectionNavTile(
leading: const Icon(AIcons.allCollection),
title: context.l10n.drawerCollectionAll,
filter: null,
);
return [
const Divider(),
...pageBookmarks.map((route) {
WidgetBuilder? pageBuilder;
Widget? trailing;
switch (route) {
case AlbumListPage.routeName:
pageBuilder = (_) => const AlbumListPage();
trailing = StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, _) => Text('${source.rawAlbums.length}'),
);
break;
case CountryListPage.routeName:
pageBuilder = (_) => const CountryListPage();
trailing = StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, _) => Text('${source.sortedCountries.length}'),
);
break;
case TagListPage.routeName:
pageBuilder = (_) => const TagListPage();
trailing = StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, _) => Text('${source.sortedTags.length}'),
);
break;
}
Widget get videoTile => CollectionNavTile(
leading: const Icon(AIcons.video),
title: context.l10n.drawerCollectionVideos,
filter: MimeFilter.video,
);
return PageNavTile(
trailing: trailing,
routeName: route,
pageBuilder: pageBuilder ?? (_) => const SizedBox(),
);
}),
];
}
Widget get favouriteTile => CollectionNavTile(
leading: const Icon(AIcons.favourite),
title: context.l10n.drawerCollectionFavourites,
filter: FavouriteFilter.instance,
);
Widget get albumListTile => NavTile(
icon: AIcons.album,
title: context.l10n.albumPageTitle,
trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, _) => Text('${source.rawAlbums.length}'),
),
routeName: AlbumListPage.routeName,
pageBuilder: (_) => const AlbumListPage(),
);
Widget get countryListTile => NavTile(
icon: AIcons.location,
title: context.l10n.countryPageTitle,
trailing: StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, _) => Text('${source.sortedCountries.length}'),
),
routeName: CountryListPage.routeName,
pageBuilder: (_) => const CountryListPage(),
);
Widget get tagListTile => NavTile(
icon: AIcons.tag,
title: context.l10n.tagPageTitle,
trailing: StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, _) => Text('${source.sortedTags.length}'),
),
routeName: TagListPage.routeName,
pageBuilder: (_) => const TagListPage(),
);
Widget get debugTile => NavTile(
icon: AIcons.debug,
title: 'Debug',
Widget get debugTile => PageNavTile(
topLevel: false,
routeName: AppDebugPage.routeName,
pageBuilder: (_) => const AppDebugPage(),

View file

@ -1,13 +1,17 @@
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CollectionNavTile extends StatelessWidget {
final Widget? leading;
final String title;
final Widget title;
final Widget? trailing;
final bool dense;
final CollectionFilter? filter;
@ -29,7 +33,7 @@ class CollectionNavTile extends StatelessWidget {
bottom: false,
child: ListTile(
leading: leading,
title: Text(title),
title: title,
trailing: trailing,
dense: dense,
onTap: () => _goToCollection(context),
@ -54,3 +58,30 @@ class CollectionNavTile extends StatelessWidget {
);
}
}
class AlbumNavTile extends StatelessWidget {
final String album;
const AlbumNavTile({
Key? key,
required this.album,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
var filter = AlbumFilter(album, source.getAlbumDisplayName(context, album));
return CollectionNavTile(
leading: DrawerFilterIcon(filter: filter),
title: DrawerFilterTitle(filter: filter),
trailing: androidFileUtils.isOnRemovableStorage(album)
? const Icon(
AIcons.removableStorage,
size: 16,
color: Colors.grey,
)
: null,
filter: filter,
);
}
}

View file

@ -0,0 +1,64 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class PageNavTile extends StatelessWidget {
final Widget? trailing;
final bool topLevel;
final String routeName;
final WidgetBuilder? pageBuilder;
const PageNavTile({
Key? key,
this.trailing,
this.topLevel = true,
required this.routeName,
required this.pageBuilder,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final _pageBuilder = pageBuilder;
return SafeArea(
top: false,
bottom: false,
child: ListTile(
key: Key('$routeName-tile'),
leading: DrawerPageIcon(route: routeName),
title: DrawerPageTitle(route: routeName),
trailing: trailing != null
? Builder(
builder: (context) => DefaultTextStyle.merge(
style: TextStyle(
color: IconTheme.of(context).color!.withOpacity(.6),
),
child: trailing!,
),
)
: null,
onTap: _pageBuilder != null
? () {
Navigator.pop(context);
if (routeName != context.currentRouteName) {
final route = MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: _pageBuilder,
);
if (topLevel) {
Navigator.pushAndRemoveUntil(
context,
route,
(route) => false,
);
} else {
Navigator.push(context, route);
}
}
}
: null,
selected: context.currentRouteName == routeName,
),
);
}
}

View file

@ -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());
}
}

View file

@ -9,7 +9,7 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
@ -29,7 +29,7 @@ class AlbumPickPage extends StatefulWidget {
static const routeName = '/album_pick';
final CollectionSource source;
final MoveType moveType;
final MoveType? moveType;
const AlbumPickPage({
Key? key,
@ -92,7 +92,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
class AlbumPickAppBar extends StatelessWidget {
final CollectionSource source;
final MoveType moveType;
final MoveType? moveType;
final AlbumChipSetActionDelegate actionDelegate;
final ValueNotifier<String> queryNotifier;
@ -117,7 +117,7 @@ class AlbumPickAppBar extends StatelessWidget {
case MoveType.move:
return context.l10n.albumPickPageTitleMove;
default:
return moveType.toString();
return context.l10n.albumPickPageTitlePick;
}
}
@ -131,40 +131,43 @@ class AlbumPickAppBar extends StatelessWidget {
filterNotifier: queryNotifier,
),
actions: [
IconButton(
icon: const Icon(AIcons.createAlbum),
onPressed: () async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => const CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: context.l10n.createAlbumTooltip,
),
PopupMenuButton<ChipSetAction>(
itemBuilder: (context) {
return [
PopupMenuItem(
value: ChipSetAction.sort,
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
),
PopupMenuItem(
value: ChipSetAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
),
];
},
onSelected: (action) {
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
if (moveType != null)
IconButton(
icon: const Icon(AIcons.add),
onPressed: () async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => const CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
Navigator.pop<String>(context, newAlbum);
}
},
tooltip: context.l10n.createAlbumTooltip,
),
MenuIconTheme(
child: PopupMenuButton<ChipSetAction>(
itemBuilder: (context) {
return [
PopupMenuItem(
value: ChipSetAction.sort,
child: MenuRow(text: context.l10n.menuActionSort, icon: const Icon(AIcons.sort)),
),
PopupMenuItem(
value: ChipSetAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: const Icon(AIcons.group)),
),
];
},
onSelected: (action) {
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
},
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
},
),
),
],
floating: true,

View file

@ -40,6 +40,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
case ChipSetAction.createAlbum:
case ChipSetAction.delete:
case ChipSetAction.rename:
return true;
@ -211,12 +212,12 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
);
if (newName == null || newName.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return;
final destinationAlbumParent = pContext.dirname(album);
final destinationAlbum = pContext.join(destinationAlbumParent, newName);
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return;
if (!(await File(destinationAlbum).exists())) {
// access to the destination parent is required to create the underlying destination folder
if (!await checkStoragePermissionForAlbums(context, {destinationAlbumParent})) return;

View file

@ -32,6 +32,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
bool isValid(Set<T> filters, ChipSetAction action) {
final hasSelection = filters.isNotEmpty;
switch (action) {
case ChipSetAction.createAlbum:
case ChipSetAction.delete:
case ChipSetAction.rename:
return false;
@ -76,10 +77,10 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
_showSortDialog(context);
break;
case ChipSetAction.map:
_goToMap(context);
_goToMap(context, filters);
break;
case ChipSetAction.stats:
_goToStats(context);
_goToStats(context, filters);
break;
case ChipSetAction.select:
context.read<Selection<FilterGridItem<T>>>().select();
@ -129,28 +130,33 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
}
}
void _goToMap(BuildContext context) {
void _goToMap(BuildContext context, Set<T> filters) {
final source = context.read<CollectionSource>();
final entries = filters.isEmpty ? source.visibleEntries : source.visibleEntries.where((entry) => filters.any((f) => f.test(entry)));
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
entries: entries.where((entry) => entry.hasGps).toList(),
),
),
);
}
void _goToStats(BuildContext context) {
void _goToStats(BuildContext context, Set<T> filters) {
final source = context.read<CollectionSource>();
final entries = filters.isEmpty ? source.visibleEntries : source.visibleEntries.where((entry) => filters.any((f) => f.test(entry)));
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
),
builder: (context) {
return StatsPage(
entries: entries.toSet(),
source: source,
);
},
),
);
}

View file

@ -8,7 +8,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/search/search_button.dart';
@ -111,7 +111,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
Widget? _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<FilterGridItem<T>>, int>(
selector: (context, selection) => selection.selection.length,
selector: (context, selection) => selection.selectedItems.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
@ -127,16 +127,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
}
List<Widget> _buildActions(AppMode appMode, Selection<FilterGridItem<T>> selection) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
PopupMenuItem<ChipSetAction> toMenuItem(ChipSetAction action, {bool enabled = true}) {
return PopupMenuItem(
value: action,
enabled: enabled && actionDelegate.canApply(selectedFilters, action),
child: MenuRow(
text: action.getText(context),
icon: action.getIcon(),
),
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
);
}
@ -152,13 +149,13 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
final buttonActions = <Widget>[];
if (isSelecting) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
final selectedFilters = selection.selectedItems.map((v) => v.filter).toSet();
final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList();
buttonActions.addAll(validActions.take(buttonActionCount).map(
(action) {
final enabled = actionDelegate.canApply(selectedFilters, action);
return IconButton(
icon: Icon(action.getIcon()),
icon: action.getIcon(),
onPressed: enabled ? () => applyAction(action) : null,
tooltip: action.getText(context),
);
@ -171,51 +168,68 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
return [
...buttonActions,
PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final menuItems = <PopupMenuEntry<ChipSetAction>>[
toMenuItem(ChipSetAction.sort),
if (widget.groupable) toMenuItem(ChipSetAction.group),
];
MenuIconTheme(
child: PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final selectedItems = selection.selectedItems;
final hasSelection = selectedItems.isNotEmpty;
final hasItems = !widget.isEmpty;
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
if (isSelecting) {
final selectedItems = selection.selection;
final menuItems = <PopupMenuEntry<ChipSetAction>>[
toMenuItem(ChipSetAction.sort),
if (widget.groupable) toMenuItem(ChipSetAction.group),
if (appMode == AppMode.main && !isSelecting)
toMenuItem(
ChipSetAction.select,
enabled: hasItems,
),
];
if (selectionRowActions.isNotEmpty) {
if (appMode == AppMode.main) {
menuItems.add(const PopupMenuDivider());
menuItems.addAll(selectionRowActions.map(toMenuItem));
if (isSelecting) {
menuItems.addAll(selectionRowActions.map(toMenuItem));
}
menuItems.addAll([
toMenuItem(
ChipSetAction.map,
enabled: otherViewEnabled,
),
toMenuItem(
ChipSetAction.stats,
enabled: otherViewEnabled,
),
]);
if (!isSelecting && actionDelegate.isValid(selectedFilters, ChipSetAction.createAlbum)) {
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(ChipSetAction.createAlbum),
]);
}
}
if (isSelecting) {
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(
ChipSetAction.selectAll,
enabled: selectedItems.length < actionDelegate.allItems.length,
),
toMenuItem(
ChipSetAction.selectNone,
enabled: hasSelection,
),
]);
}
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(
ChipSetAction.selectAll,
enabled: selectedItems.length < actionDelegate.allItems.length,
),
toMenuItem(
ChipSetAction.selectNone,
enabled: selectedItems.isNotEmpty,
),
]);
} else if (appMode == AppMode.main) {
menuItems.addAll([
toMenuItem(
ChipSetAction.select,
enabled: !widget.isEmpty,
),
toMenuItem(ChipSetAction.map),
toMenuItem(ChipSetAction.stats),
toMenuItem(ChipSetAction.createAlbum),
]);
}
return menuItems;
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
},
return menuItems;
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
},
),
),
];
}

View file

@ -13,8 +13,8 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/thumbnail/image.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';

Some files were not shown because too many files have changed in this diff Show more