diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc47b01f..1208f5714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.4.7] - 2021-08-06 +### Added +- Map +- Viewer: action to copy to clipboard +- integration with Android global search (Samsung Finder etc.) + +### Fixed +- auto album identification and naming +- opening HEIC images from downloads content URI on Android R+ + ## [v1.4.6] - 2021-07-22 ### Added - Albums / Countries / Tags: multiple selection diff --git a/android/app/build.gradle b/android/app/build.gradle index 01584967c..999193448 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,6 +6,8 @@ plugins { id 'com.google.firebase.crashlytics' } +def appId = "deckers.thibault.aves" + // Flutter properties def localProperties = new Properties() @@ -52,13 +54,18 @@ android { } defaultConfig { - applicationId "deckers.thibault.aves" + applicationId appId + // minSdkVersion constraints: + // - Flutter & other plugins: 16 + // - google_maps_flutter v2.0.5: 20 + // - Aves native: 19 minSdkVersion 20 targetSdkVersion 30 versionCode flutterVersionCode.toInteger() versionName flutterVersionName manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']] multiDexEnabled true + resValue 'string', 'search_provider', "${appId}.search_provider" } signingConfigs { @@ -73,9 +80,11 @@ android { buildTypes { debug { applicationIdSuffix ".debug" + resValue 'string', 'search_provider', "${appId}.debug.search_provider" } profile { applicationIdSuffix ".profile" + resValue 'string', 'search_provider', "${appId}.profile.search_provider" } release { // specify architectures, to specifically exclude native libs for x86, diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1aa2beace..09725f98b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ tools:ignore="ScopedStorage" /> - + @@ -38,26 +38,21 @@ - + android:roundIcon="@mipmap/ic_launcher_round" + tools:targetApi="lollipop"> - - - @@ -65,6 +60,7 @@ + @@ -108,11 +104,26 @@ + + + + + + + + + + diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 2f726d6b6..773f2ef19 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -1,5 +1,7 @@ package deckers.thibault.aves +import android.annotation.SuppressLint +import android.app.SearchManager import android.content.Intent import android.net.Uri import android.os.Build @@ -29,10 +31,23 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { Log.i(LOG_TAG, "onCreate intent=$intent") +// StrictMode.setThreadPolicy( +// StrictMode.ThreadPolicy.Builder() +// .detectAll() +// .penaltyLog() +// .build() +// ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy.Builder() +// .detectAll() +// .penaltyLog() +// .build() +// ) super.onCreate(savedInstanceState) val messenger = flutterEngine!!.dartExecutor.binaryMessenger + // dart -> platform -> dart MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) @@ -40,18 +55,20 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) + MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler()) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) + // result streaming: dart -> platform ->->-> dart StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } - // Media Store change monitoring + // change monitoring: platform -> dart mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply { EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this) } @@ -60,9 +77,11 @@ class MainActivity : FlutterActivity() { } // intent handling + // notification: platform -> dart intentStreamHandler = IntentStreamHandler().apply { EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this) } + // detail fetch: dart -> platform intentDataMap = extractIntentData(intent) MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -74,6 +93,11 @@ class MainActivity : FlutterActivity() { } } + // notification: platform -> dart + errorStreamHandler = ErrorStreamHandler().apply { + EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { setupShortcuts() } @@ -93,31 +117,34 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - DOCUMENT_TREE_ACCESS_REQUEST -> { - val treeUri = data?.data - if (resultCode != RESULT_OK || treeUri == null) { - onPermissionResult(requestCode, null) - return - } + 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) + } + } - // save access permissions across reboots - val takeFlags = (data.flags - and (Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) - contentResolver.takePersistableUriPermission(treeUri, takeFlags) + @SuppressLint("WrongConstant") + private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) { + val treeUri = data?.data + if (resultCode != RESULT_OK || treeUri == null) { + onPermissionResult(requestCode, null) + return + } - // resume pending action - onPermissionResult(requestCode, treeUri) - } - DELETE_PERMISSION_REQUEST -> { - // delete permission may be requested on Android 10+ only - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) - } - } - CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> { - onPermissionResult(requestCode, data?.data) - } + // save access permissions across reboots + val takeFlags = (data.flags + and (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) + contentResolver.takePersistableUriPermission(treeUri, takeFlags) + + // resume pending action + onPermissionResult(requestCode, treeUri) + } + + private fun onDeletePermissionResult(resultCode: Int) { + // delete permission may be requested on Android 10+ only + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) } } @@ -146,6 +173,20 @@ class MainActivity : FlutterActivity() { "mimeType" to intent.type, ) } + Intent.ACTION_SEARCH -> { + val viewUri = intent.dataString + return if (viewUri != null) hashMapOf( + "action" to "view", + "uri" to viewUri, + "mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY), + ) else hashMapOf( + "action" to "search", + "query" to intent.getStringExtra(SearchManager.QUERY), + ) + } + else -> { + Log.w(LOG_TAG, "unhandled intent action=${intent?.action}") + } } return HashMap() } @@ -211,6 +252,10 @@ class MainActivity : FlutterActivity() { handler.onDenied() } } + + var errorStreamHandler: ErrorStreamHandler? = null + + fun notifyError(error: String) = errorStreamHandler?.notifyError(error) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt new file mode 100644 index 000000000..df5d58108 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -0,0 +1,188 @@ +package deckers.thibault.aves + +import android.app.SearchManager +import android.content.ContentProvider +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.util.Log +import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.utils.LogUtils +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.FlutterCallbackInformation +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() { + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + return selectionArgs?.firstOrNull()?.let { query -> + // Samsung Finder does not support: + // - resource ID as value for SUGGEST_COLUMN_ICON_1 + // - SUGGEST_COLUMN_ICON_2 + // - SUGGEST_COLUMN_RESULT_CARD_IMAGE + val columns = arrayOf( + SearchManager.SUGGEST_COLUMN_INTENT_DATA, + SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) SearchManager.SUGGEST_COLUMN_CONTENT_TYPE else "mimeType", + SearchManager.SUGGEST_COLUMN_TEXT_1, + SearchManager.SUGGEST_COLUMN_TEXT_2, + SearchManager.SUGGEST_COLUMN_ICON_1, + ) + + val matrixCursor = MatrixCursor(columns) + context?.let { context -> + val searchShortcutTitle = "${context.resources.getString(R.string.search_shortcut_short_label)} $query" + val searchShortcutIcon = context.resourceUri(R.mipmap.ic_shortcut_search) + matrixCursor.addRow(arrayOf(null, null, null, searchShortcutTitle, null, searchShortcutIcon)) + + runBlocking { + getSuggestions(context, query).forEach { + val data = it["data"] + val mimeType = it["mimeType"] + val title = it["title"] + val subtitle = it["subtitle"] + val iconUri = it["iconUri"] + matrixCursor.addRow(arrayOf(data, mimeType, mimeType, title, subtitle, iconUri)) + } + } + } + matrixCursor + } + } + + private suspend fun getSuggestions(context: Context, query: String): List { + if (backgroundFlutterEngine == null) { + initFlutterEngine(context) + } + + val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger + val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL) + backgroundChannel.setMethodCallHandler(this) + + return suspendCoroutine { cont -> + GlobalScope.launch { + context.runOnUiThread { + backgroundChannel.invokeMethod("getSuggestions", hashMapOf( + "query" to query, + "locale" to Locale.getDefault().toString(), + ), object : MethodChannel.Result { + override fun success(result: Any?) { + @Suppress("UNCHECKED_CAST") + cont.resume(result as List) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails")) + } + + override fun notImplemented() { + cont.resumeWithException(NotImplementedError("getSuggestions")) + } + }) + } + } + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialized" -> { + Log.d(LOG_TAG, "background channel is ready") + result.success(null) + } + else -> result.notImplemented() + } + } + + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri = + throw UnsupportedOperationException() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException() + + companion object { + private val LOG_TAG = LogUtils.createTag() + private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/global_search_background" + const val SHARED_PREFERENCES_KEY = "platform_search" + const val CALLBACK_HANDLE_KEY = "callback_handle" + + private var backgroundFlutterEngine: FlutterEngine? = null + + private suspend fun initFlutterEngine(context: Context) { + val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0) + if (callbackHandle == 0L) { + Log.e(LOG_TAG, "failed to retrieve registered callback handle") + return + } + + lateinit var flutterLoader: FlutterLoader + context.runOnUiThread { + // initialization must happen on the main thread + flutterLoader = FlutterInjector.instance().flutterLoader().apply { + startInitialization(context) + ensureInitializationComplete(context, null) + } + } + + val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callbackInfo == null) { + Log.e(LOG_TAG, "failed to find callback information") + return + } + + val args = DartExecutor.DartCallback( + context.assets, + flutterLoader.findAppBundlePath(), + callbackInfo + ) + context.runOnUiThread { + // initialization must happen on the main thread + backgroundFlutterEngine = FlutterEngine(context).apply { + dartExecutor.executeDartCallback(args) + } + } + } + + // convenience methods + + private suspend fun Context.runOnUiThread(r: Runnable) { + suspendCoroutine { cont -> + Handler(mainLooper).post { + r.run() + cont.resume(true) + } + } + } + + private fun Context.resourceUri(resourceId: Int): Uri = with(resources) { + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(getResourcePackageName(resourceId)) + .appendPath(getResourceTypeName(resourceId)) + .appendPath(getResourceEntryName(resourceId)) + .build() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 51a883a0a..04afd10b8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -1,8 +1,6 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver -import android.content.Context -import android.content.Intent +import android.content.* import android.content.pm.ApplicationInfo import android.content.res.Configuration import android.net.Uri @@ -12,7 +10,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.request.RequestOptions import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus +import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils @@ -31,34 +29,13 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) } - "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) } - "edit" -> { - val title = call.argument("title") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val mimeType = call.argument("mimeType") - result.success(edit(title, uri, mimeType)) - } - "open" -> { - val title = call.argument("title") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val mimeType = call.argument("mimeType") - result.success(open(title, uri, mimeType)) - } - "openMap" -> { - val geoUri = call.argument("geoUri")?.let { Uri.parse(it) } - result.success(openMap(geoUri)) - } - "setAs" -> { - val title = call.argument("title") - val uri = call.argument("uri")?.let { Uri.parse(it) } - val mimeType = call.argument("mimeType") - result.success(setAs(title, uri, mimeType)) - } - "share" -> { - val title = call.argument("title") - val urisByMimeType = call.argument>>("urisByMimeType")!! - result.success(shareMultiple(title, urisByMimeType)) - } + "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) } + "copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) } + "edit" -> safe(call, result, ::edit) + "open" -> safe(call, result, ::open) + "openMap" -> safe(call, result, ::openMap) + "setAs" -> safe(call, result, ::setAs) + "share" -> safe(call, result, ::share) else -> result.notImplemented() } } @@ -156,77 +133,131 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } } - private fun edit(title: String?, uri: Uri?, mimeType: String?): Boolean { - uri ?: return false + private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) { + val uri = call.argument("uri")?.let { Uri.parse(it) } + val label = call.argument("label") + if (uri == null) { + result.error("copyToClipboard-args", "failed because of missing arguments", null) + return + } + + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (clipboard != null) { + val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(uri)) + clipboard.setPrimaryClip(clip) + result.success(true) + } else { + result.success(false) + } + } + + private fun edit(call: MethodCall, result: MethodChannel.Result) { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + if (uri == null) { + result.error("edit-args", "failed because of missing arguments", null) + return + } val intent = Intent(Intent.ACTION_EDIT) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .setDataAndType(getShareableUri(uri), mimeType) - return safeStartActivityChooser(title, intent) + val started = safeStartActivityChooser(title, intent) + + result.success(started) } - private fun open(title: String?, uri: Uri?, mimeType: String?): Boolean { - uri ?: return false + private fun open(call: MethodCall, result: MethodChannel.Result) { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + if (uri == null) { + result.error("open-args", "failed because of missing arguments", null) + return + } val intent = Intent(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(getShareableUri(uri), mimeType) - return safeStartActivityChooser(title, intent) + val started = safeStartActivityChooser(title, intent) + + result.success(started) } - private fun openMap(geoUri: Uri?): Boolean { - geoUri ?: return false + private fun openMap(call: MethodCall, result: MethodChannel.Result) { + val geoUri = call.argument("geoUri")?.let { Uri.parse(it) } + if (geoUri == null) { + result.error("openMap-args", "failed because of missing arguments", null) + return + } val intent = Intent(Intent.ACTION_VIEW, geoUri) - return safeStartActivity(intent) + val started = safeStartActivity(intent) + + result.success(started) } - private fun setAs(title: String?, uri: Uri?, mimeType: String?): Boolean { - uri ?: return false + private fun setAs(call: MethodCall, result: MethodChannel.Result) { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + if (uri == null) { + result.error("setAs-args", "failed because of missing arguments", null) + return + } val intent = Intent(Intent.ACTION_ATTACH_DATA) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(getShareableUri(uri), mimeType) - return safeStartActivityChooser(title, intent) + val started = safeStartActivityChooser(title, intent) + + result.success(started) } - private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean { - val intent = Intent(Intent.ACTION_SEND) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setType(mimeType) - .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) - return safeStartActivityChooser(title, intent) - } - - private fun shareMultiple(title: String?, urisByMimeType: Map>?): Boolean { - urisByMimeType ?: return false + private fun share(call: MethodCall, result: MethodChannel.Result) { + val title = call.argument("title") + val urisByMimeType = call.argument>>("urisByMimeType") + if (urisByMimeType == null) { + result.error("setAs-args", "failed because of missing arguments", null) + return + } val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { Uri.parse(it) }) val mimeTypes = urisByMimeType.keys.toTypedArray() // simplify share intent for a single item, as some apps can handle one item but not more - if (uriList.size == 1) { - return shareSingle(title, uriList.first(), mimeTypes.first()) - } + val started = if (uriList.size == 1) { + val uri = uriList.first() + val mimeType = mimeTypes.first() - var mimeType = "*/*" - if (mimeTypes.size == 1) { - // items have the same mime type & subtype - mimeType = mimeTypes.first() + val intent = Intent(Intent.ACTION_SEND) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setType(mimeType) + .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) + safeStartActivityChooser(title, intent) } else { - // items have different subtypes - val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct() - if (mimeTypeTypes.size == 1) { - // items have the same mime type - mimeType = "${mimeTypeTypes.first()}/*" + var mimeType = "*/*" + if (mimeTypes.size == 1) { + // items have the same mime type & subtype + mimeType = mimeTypes.first() + } else { + // items have different subtypes + val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct() + if (mimeTypeTypes.size == 1) { + // items have the same mime type + mimeType = "${mimeTypeTypes.first()}/*" + } } + + val intent = Intent(Intent.ACTION_SEND_MULTIPLE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) + .setType(mimeType) + safeStartActivityChooser(title, intent) } - val intent = Intent(Intent.ACTION_SEND_MULTIPLE) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) - .setType(mimeType) - return safeStartActivityChooser(title, intent) + result.success(started) } private fun safeStartActivity(intent: Intent): Boolean { @@ -255,7 +286,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_FILE -> { uri.path?.let { path -> - val authority = "${context.applicationContext.packageName}.fileprovider" + val authority = "${context.applicationContext.packageName}.file_provider" FileProvider.getUriForFile(context, authority, File(path)) } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt index 684edfc12..24b81b739 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt @@ -8,6 +8,7 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import deckers.thibault.aves.MainActivity import deckers.thibault.aves.R +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -20,23 +21,31 @@ import java.util.* class AppShortcutHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "canPin" -> result.success(canPin()) - "pin" -> { - GlobalScope.launch(Dispatchers.IO) { pin(call) } - result.success(null) - } + "canPin" -> safe(call, result, ::canPin) + "pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) } else -> result.notImplemented() } } - private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) + private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) - private fun pin(call: MethodCall) { - if (!canPin()) return + private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + result.success(isSupported()) + } - val label = call.argument("label") ?: return + private fun pin(call: MethodCall, result: MethodChannel.Result) { + val label = call.argument("label") val iconBytes = call.argument("iconBytes") - val filters = call.argument>("filters") ?: return + val filters = call.argument>("filters") + if (label == null || filters == null) { + result.error("pin-args", "failed because of missing arguments", null) + return + } + + if (!isSupported()) { + result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null) + return + } var icon: IconCompat? = null if (iconBytes?.isNotEmpty() == true) { @@ -62,6 +71,8 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler { .setIntent(intent) .build() ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) + + result.success(true) } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt index 5207bde7d..6e2f770c5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/Coresult.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves.channel.calls +import deckers.thibault.aves.MainActivity import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope @@ -8,24 +9,42 @@ import kotlinx.coroutines.launch import kotlin.reflect.KSuspendFunction2 // ensure `result` methods are called on the main looper thread -class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result { +class Coresult internal constructor(private val call: MethodCall, private val methodResult: MethodChannel.Result) : MethodChannel.Result { private val mainScope = CoroutineScope(Dispatchers.Main) override fun success(result: Any?) { - mainScope.launch { methodResult.success(result) } + mainScope.launch { + try { + methodResult.success(result) + } catch (e: Exception) { + MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=$e") + } + } } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - mainScope.launch { methodResult.error(errorCode, errorMessage, errorDetails) } + mainScope.launch { + try { + methodResult.error(errorCode, errorMessage, errorDetails) + } catch (e: Exception) { + MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=$e") + } + } } override fun notImplemented() { - mainScope.launch { methodResult.notImplemented() } + mainScope.launch { + try { + methodResult.notImplemented() + } catch (e: Exception) { + MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=$e") + } + } } companion object { fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) { - val res = Coresult(result) + val res = Coresult(call, result) try { function(call, res) } catch (e: Exception) { @@ -33,12 +52,12 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu } } - suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2) { - val res = Coresult(result) + suspend fun safeSuspend(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2) { + val res = Coresult(call, result) try { function(call, res) } catch (e: Exception) { - res.error("safe-exception", e.message, e.stackTraceToString()) + res.error("safeSuspend-exception", e.message, e.stackTraceToString()) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index ba9b886da..70292b9ea 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -1,12 +1,13 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor import android.graphics.BitmapFactory import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface @@ -37,8 +38,15 @@ import java.util.* class DebugHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getContextDirs" -> result.success(getContextDirs()) - "getEnv" -> result.success(System.getenv()) + "crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50) + "exception" -> throw TestException() + "safeException" -> safe(call, result) { _, _ -> throw TestException() } + "exceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { throw TestException() } + "safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } } + + "getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) } + "getEnv" -> safe(call, result, ::getEnv) + "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) } "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) } "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) } @@ -49,24 +57,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } - private fun getContextDirs() = hashMapOf( - "cacheDir" to context.cacheDir, - "filesDir" to context.filesDir, - "obbDir" to context.obbDir, - "externalCacheDir" to context.externalCacheDir, - ).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - putAll( - hashMapOf( - "codeCacheDir" to context.codeCacheDir, - "noBackupFilesDir" to context.noBackupFilesDir, + private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + val dirs = hashMapOf( + "cacheDir" to context.cacheDir, + "filesDir" to context.filesDir, + "obbDir" to context.obbDir, + "externalCacheDir" to context.externalCacheDir, + ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + putAll( + hashMapOf( + "codeCacheDir" to context.codeCacheDir, + "noBackupFilesDir" to context.noBackupFilesDir, + ) ) - ) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - put("dataDir", context.dataDir) - } - }.mapValues { it.value?.path } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + put("dataDir", context.dataDir) + } + }.mapValues { it.value?.path } + + result.success(dirs) + } + + private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + result.success(System.getenv()) + } private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } @@ -105,7 +121,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } var contentUri: Uri = uri - if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + if (StorageUtils.isMediaStoreContentUri(uri)) { uri.tryParseId()?.let { id -> contentUri = when { isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) @@ -317,4 +333,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/debug" } + + class TestException internal constructor() : RuntimeException("oops") } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index 287a0b87b..a99906901 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -1,6 +1,7 @@ package deckers.thibault.aves.channel.calls import android.os.Build +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -8,17 +9,18 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler class DeviceHandler : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getPerformanceClass" -> result.success(getPerformanceClass()) + "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) else -> result.notImplemented() } } - private fun getPerformanceClass(): Int { + private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { // TODO TLAD uncomment when the future is here // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { -// return Build.VERSION.MEDIA_PERFORMANCE_CLASS +// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) +// return // } - return Build.VERSION.SDK_INT + result.success(Build.VERSION.SDK_INT) } companion object { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt index 6e0f1252e..9bc84c83f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt @@ -12,7 +12,7 @@ import com.drew.imaging.ImageMetadataReader import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.xmp.XmpDirectory import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus +import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MultiPage @@ -44,7 +44,7 @@ import java.util.* class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) } + "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getExifThumbnails) } "extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) } "extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) } "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } @@ -193,7 +193,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { } } } - val authority = "${context.applicationContext.packageName}.fileprovider" + val authority = "${context.applicationContext.packageName}.file_provider" val uri = if (displayName != null) { // add extension to ease type identification when sharing this content val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt index 874c32d97..17ced0c0b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GeocodingHandler.kt @@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls import android.content.Context import android.location.Geocoder +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -18,7 +19,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getAddress" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::getAddress) } + "getAddress" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAddress) } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt new file mode 100644 index 000000000..770f6beac --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt @@ -0,0 +1,42 @@ +package deckers.thibault.aves.channel.calls + +import android.app.Activity +import android.content.Context +import android.util.Log +import deckers.thibault.aves.SearchSuggestionsProvider +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import deckers.thibault.aves.utils.LogUtils +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) } + else -> result.notImplemented() + } + } + + private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { + val callbackHandle = call.argument("callbackHandle")?.toLong() + if (callbackHandle == null) { + result.error("registerCallback-args", "failed because of missing arguments", null) + return + } + + context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + .edit() + .putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle) + .apply() + result.success(true) + } + + companion object { + private val LOG_TAG = LogUtils.createTag() + const val CHANNEL = "deckers.thibault/aves/global_search" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index ac092169f..8b35b4d9a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -5,7 +5,7 @@ import android.graphics.Rect import android.net.Uri import com.bumptech.glide.Glide import deckers.thibault.aves.channel.calls.Coresult.Companion.safe -import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus +import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher @@ -32,10 +32,10 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) } - "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) } - "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) } - "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) } - "rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) } + "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } + "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } + "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } + "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 895c58eda..bd9e7967c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -1,6 +1,5 @@ package deckers.thibault.aves.channel.calls -import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.database.Cursor @@ -692,7 +691,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } var contentUri: Uri = uri - if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + if (StorageUtils.isMediaStoreContentUri(uri)) { uri.tryParseId()?.let { id -> contentUri = when { isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index 9710f8962..4a2411bbc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -23,13 +23,13 @@ import java.util.* class StorageHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getStorageVolumes" -> safe(call, result, ::getStorageVolumes) - "getFreeSpace" -> safe(call, result, ::getFreeSpace) - "getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories) - "getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories) - "getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories) + "getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) } + "getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) } + "getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) } + "getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) } + "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) - "deleteEmptyDirectories" -> safe(call, result, ::deleteEmptyDirectories) + "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } else -> result.notImplemented() } @@ -38,7 +38,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { val volumes = ArrayList>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val sm = context.getSystemService(StorageManager::class.java) + val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager if (sm != null) { for (volumePath in getVolumePaths(context)) { try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt index 0f09aff8f..6d4736dc6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/TimeHandler.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves.channel.calls +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -8,11 +9,15 @@ import java.util.* class TimeHandler : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getDefaultTimeZone" -> result.success(TimeZone.getDefault().id) + "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) else -> result.notImplemented() } } + private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + result.success(TimeZone.getDefault().id) + } + companion object { const val CHANNEL = "deckers.thibault/aves/time" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt index c1b1cda0c..bad2bf6eb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt @@ -17,7 +17,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { "keepScreenOn" -> safe(call, result, ::keepScreenOn) "isRotationLocked" -> safe(call, result, ::isRotationLocked) "requestOrientation" -> safe(call, result, ::requestOrientation) - "canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + "canSetCutoutMode" -> safe(call, result, ::canSetCutoutMode) "setCutoutMode" -> safe(call, result, ::setCutoutMode) else -> result.notImplemented() } @@ -60,6 +60,10 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { result.success(true) } + private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + } + private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) { val use = call.argument("use") if (use == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index c10ba3e55..2a32f93ba 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -24,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide +import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.UriUtils.tryParseId import io.flutter.plugin.common.MethodChannel @@ -130,7 +131,7 @@ class ThumbnailFetcher internal constructor( svgFetch -> SvgThumbnail(context, uri) tiffFetch -> TiffImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId) - else -> uri + else -> StorageUtils.getGlideSafeUri(uri, mimeType) } Glide.with(context) .asBitmap() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt new file mode 100644 index 000000000..b5eab9563 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ErrorStreamHandler.kt @@ -0,0 +1,25 @@ +package deckers.thibault.aves.channel.streams + +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink + +class ErrorStreamHandler : EventChannel.StreamHandler { + // cannot use `lateinit` because we cannot guarantee + // its initialization in `onListen` at the right time + // e.g. when resuming the app after the activity got destroyed + private var eventSink: EventSink? = null + + override fun onListen(arguments: Any?, eventSink: EventSink) { + this.eventSink = eventSink + } + + override fun onCancel(arguments: Any?) {} + + fun notifyError(error: String) { + eventSink?.success(error) + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/error" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index dbd1ee080..59dfd47d7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -120,7 +120,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen } else if (mimeType == MimeTypes.TIFF) { TiffImage(activity, uri, pageId) } else { - uri + StorageUtils.getGlideSafeUri(uri, mimeType) } val target = Glide.with(activity) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 0434ceaab..cd7e590a4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -39,7 +39,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? handler = Handler(Looper.getMainLooper()) when (op) { - "requestVolumeAccess" -> requestVolumeAccess() + "requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } @@ -83,18 +83,20 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? putExtra(Intent.EXTRA_TITLE, name) } MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri -> - try { - activity.contentResolver.openOutputStream(uri)?.use { output -> - output as FileOutputStream - // truncate is necessary when overwriting a longer file - output.channel.truncate(0) - output.write(bytes) + GlobalScope.launch(Dispatchers.IO) { + try { + activity.contentResolver.openOutputStream(uri)?.use { output -> + output as FileOutputStream + // truncate is necessary when overwriting a longer file + output.channel.truncate(0) + output.write(bytes) + } + success(true) + } catch (e: Exception) { + error("createFile-write", "failed to write file at uri=$uri", e.message) } - success(true) - } catch (e: Exception) { - error("createFile-write", "failed to write file at uri=$uri", e.message) + endOfStream() } - endOfStream() }, { success(null) endOfStream() @@ -115,13 +117,15 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? type = mimeType } MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri -> - activity.contentResolver.openInputStream(uri)?.use { input -> - val buffer = ByteArray(BUFFER_SIZE) - var len: Int - while (input.read(buffer).also { len = it } != -1) { - success(buffer.copyOf(len)) + GlobalScope.launch(Dispatchers.IO) { + activity.contentResolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + success(buffer.copyOf(len)) + } + endOfStream() } - endOfStream() } }, { success(ByteArray(0)) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index 6057abddb..e546681c8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -142,7 +142,7 @@ abstract class ImageProvider { } else if (sourceMimeType == MimeTypes.TIFF) { TiffImage(context, sourceUri, pageId) } else { - sourceUri + StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) } // request a fresh image with the highest quality format diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt index 2cfe1844b..63d0afbde 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProviderFactory.kt @@ -2,18 +2,17 @@ package deckers.thibault.aves.model.provider import android.content.ContentResolver import android.net.Uri -import android.provider.MediaStore +import deckers.thibault.aves.utils.StorageUtils import java.util.* object ImageProviderFactory { fun getProvider(uri: Uri): ImageProvider? { return when (uri.scheme?.lowercase(Locale.ROOT)) { ContentResolver.SCHEME_CONTENT -> { - // a URI's authority is [userinfo@]host[:port] - // but we only want the host when comparing to Media Store's "authority" - return when (uri.host?.lowercase(Locale.ROOT)) { - MediaStore.AUTHORITY -> MediaStoreImageProvider() - else -> ContentImageProvider() + if (StorageUtils.isMediaStoreContentUri(uri)) { + MediaStoreImageProvider() + } else { + ContentImageProvider() } } ContentResolver.SCHEME_FILE -> FileImageProvider() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 8a180a0ab..cfb0bb307 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -25,7 +25,7 @@ object PermissionManager { var intent: Intent? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val sm = activity.getSystemService(StorageManager::class.java) + val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 49eea7d96..84d51ecf3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -3,6 +3,7 @@ package deckers.thibault.aves.utils import android.Manifest import android.annotation.SuppressLint import android.content.ContentResolver +import android.content.ContentUris import android.content.Context import android.content.pm.PackageManager import android.media.MediaMetadataRetriever @@ -15,7 +16,10 @@ import android.text.TextUtils import android.util.Log import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat +import deckers.thibault.aves.utils.MimeTypes.isImage +import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath +import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.io.FileNotFoundException import java.io.InputStream @@ -183,14 +187,13 @@ object StorageUtils { // /storage/10F9-3F13/Pictures/ -> 10F9-3F13 private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - context.getSystemService(StorageManager::class.java)?.let { sm -> - sm.getStorageVolume(File(anyPath))?.let { volume -> - if (volume.isPrimary) { - return "primary" - } - volume.uuid?.let { uuid -> - return uuid.uppercase(Locale.ROOT) - } + val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + sm?.getStorageVolume(File(anyPath))?.let { volume -> + if (volume.isPrimary) { + return "primary" + } + volume.uuid?.let { uuid -> + return uuid.uppercase(Locale.ROOT) } } } @@ -218,7 +221,8 @@ object StorageUtils { } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - context.getSystemService(StorageManager::class.java)?.let { sm -> + val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager + if (sm != null) { for (volumePath in getVolumePaths(context)) { try { val volume = sm.getStorageVolume(File(volumePath)) @@ -395,7 +399,7 @@ object StorageUtils { return !onPrimaryVolume } - private fun isMediaStoreContentUri(uri: Uri?): Boolean { + fun isMediaStoreContentUri(uri: Uri?): Boolean { uri ?: return false // a URI's authority is [userinfo@]host[:port] // but we only want the host when comparing to Media Store's "authority" @@ -407,7 +411,7 @@ object StorageUtils { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { val path = uri.path path ?: return uri - // from Android R, accessing the original URI for a file media content yields a `SecurityException` + // from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException` if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) { // "Caller must hold ACCESS_MEDIA_LOCATION permission to access original" if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) { @@ -418,6 +422,24 @@ object StorageUtils { return uri } + // As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used + // to work around a bug from Android Q where metadata redaction corrupts HEIC images. + // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` + // for some content URIs (e.g. `content://media/external_primary/downloads/...`) + // so we build a typical `images` or `videos` content URI from the original content ID. + fun getGlideSafeUri(uri: Uri, mimeType: String): Uri { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { + uri.tryParseId()?.let { id -> + return when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + } + } + return uri + } + fun openInputStream(context: Context, uri: Uri): InputStream? { val effectiveUri = getOriginalUri(context, uri) return try { diff --git a/android/app/src/main/res/xml/searchable.xml b/android/app/src/main/res/xml/searchable.xml new file mode 100644 index 000000000..d86475b2f --- /dev/null +++ b/android/app/src/main/res/xml/searchable.xml @@ -0,0 +1,8 @@ + + diff --git a/android/build.gradle b/android/build.gradle index d1dfbc412..37844e272 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.5.20' + ext.kotlin_version = '1.5.21' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + 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.firebase:firebase-crashlytics-gradle:2.7.1' diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 6d2751c0c..b83b4bcb4 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/lib/image_providers/app_icon_image_provider.dart b/lib/image_providers/app_icon_image_provider.dart index a51ea806c..91aecf1ac 100644 --- a/lib/image_providers/app_icon_image_provider.dart +++ b/lib/image_providers/app_icon_image_provider.dart @@ -1,6 +1,7 @@ import 'dart:ui' as ui show Codec; import 'package:aves/services/android_app_service.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -49,23 +50,18 @@ class AppIconImage extends ImageProvider { } } -class AppIconImageKey { +@immutable +class AppIconImageKey extends Equatable { final String packageName; final double size; final double scale; + @override + List get props => [packageName, size, scale]; + const AppIconImageKey({ required this.packageName, required this.size, this.scale = 1.0, }); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is AppIconImageKey && other.packageName == packageName && other.size == size && other.scale == scale; - } - - @override - int get hashCode => hashValues(packageName, size, scale); } diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index 0cc1f2c4a..75908bb30 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'dart:ui' as ui show Codec; import 'package:aves/services/services.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -62,7 +63,8 @@ class RegionProvider extends ImageProvider { void pause() => imageFileService.cancelRegion(key); } -class RegionProviderKey { +@immutable +class RegionProviderKey extends Equatable { // do not store the entry as it is, because the key should be constant // but the entry attributes may change over time final String uri, mimeType; @@ -72,6 +74,9 @@ class RegionProviderKey { final Rectangle region; final Size imageSize; + @override + List get props => [uri, pageId, rotationDegrees, isFlipped, sampleSize, region, imageSize]; + const RegionProviderKey({ required this.uri, required this.mimeType, @@ -83,24 +88,6 @@ class RegionProviderKey { required this.imageSize, }); - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize; - } - - @override - int get hashCode => hashValues( - uri, - mimeType, - pageId, - rotationDegrees, - isFlipped, - sampleSize, - region, - imageSize, - ); - @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}'; } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index 309f696cc..5e64e045f 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -1,6 +1,7 @@ import 'dart:ui' as ui show Codec; import 'package:aves/services/services.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -63,7 +64,8 @@ class ThumbnailProvider extends ImageProvider { void pause() => imageFileService.cancelThumbnail(key); } -class ThumbnailProviderKey { +@immutable +class ThumbnailProviderKey extends Equatable { // do not store the entry as it is, because the key should be constant // but the entry attributes may change over time final String uri, mimeType; @@ -73,6 +75,9 @@ class ThumbnailProviderKey { final int dateModifiedSecs; final double extent; + @override + List get props => [uri, pageId, dateModifiedSecs, extent]; + const ThumbnailProviderKey({ required this.uri, required this.mimeType, @@ -83,20 +88,6 @@ class ThumbnailProviderKey { this.extent = 0, }); - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ThumbnailProviderKey && other.uri == uri && other.pageId == pageId && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent; - } - - @override - int get hashCode => hashValues( - uri, - pageId, - dateModifiedSecs, - extent, - ); - @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 823644af0..23e11f9f5 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -3,15 +3,20 @@ import 'dart:ui' as ui show Codec; import 'package:aves/services/services.dart'; import 'package:aves/utils/pedantic.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -class UriImage extends ImageProvider { +@immutable +class UriImage extends ImageProvider with EquatableMixin { final String uri, mimeType; final int? pageId, rotationDegrees, expectedContentLength; final bool isFlipped; final double scale; + @override + List get props => [uri, pageId, rotationDegrees, isFlipped, scale]; + const UriImage({ required this.uri, required this.mimeType, @@ -71,22 +76,6 @@ class UriImage extends ImageProvider { } } - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is UriImage && other.uri == uri && other.mimeType == mimeType && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.pageId == pageId && other.scale == scale; - } - - @override - int get hashCode => hashValues( - uri, - mimeType, - rotationDegrees, - isFlipped, - pageId, - scale, - ); - @override String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}'; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fb3bcdedf..9c666f5a5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -62,6 +62,8 @@ "chipActionCreateAlbum": "Create album", "@chipActionCreateAlbum": {}, + "entryActionCopyToClipboard": "Copy to clipboard", + "@entryActionCopyToClipboard": {}, "entryActionDelete": "Delete", "@entryActionDelete": {}, "entryActionExport": "Export", @@ -319,6 +321,8 @@ "@menuActionSort": {}, "menuActionGroup": "Group", "@menuActionGroup": {}, + "menuActionMap": "Map", + "@menuActionMap": {}, "menuActionStats": "Stats", "@menuActionStats": {}, @@ -704,6 +708,9 @@ "settingsCoordinateFormatTitle": "Coordinate Format", "@settingsCoordinateFormatTitle": {}, + "mapPageTitle": "Map", + "@mapPageTitle": {}, + "statsPageTitle": "Stats", "@statsPageTitle": {}, "statsImage": "{count, plural, =1{image} other{images}}", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7b2064a63..3622d2ec7 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -31,6 +31,7 @@ "chipActionSetCover": "대표 이미지 변경", "chipActionCreateAlbum": "앨범 만들기", + "entryActionCopyToClipboard": "클립보드에 복사", "entryActionDelete": "삭제", "entryActionExport": "내보내기", "entryActionInfo": "상세정보", @@ -146,6 +147,7 @@ "menuActionSort": "정렬", "menuActionGroup": "묶음", + "menuActionMap": "지도", "menuActionStats": "통계", "aboutPageTitle": "앱 정보", @@ -337,6 +339,8 @@ "settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현", + "mapPageTitle": "지도", + "statsPageTitle": "통계", "statsImage": "{count, plural, other{사진}}", "statsVideo": "{count, plural, other{동영상}}", diff --git a/lib/main.dart b/lib/main.dart index 5758785c4..e1abba8bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'dart:isolate'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/aves_app.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; void main() { @@ -20,12 +20,11 @@ void main() { // // flutter run --profile --trace-skia - Isolate.current.addErrorListener(RawReceivePort((pair) async { + initPlatformServices(); + + Isolate.current.addErrorListener(RawReceivePort((pair) { final List errorAndStacktrace = pair; - await FirebaseCrashlytics.instance.recordError( - errorAndStacktrace.first, - errorAndStacktrace.last, - ); + reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last); }).sendPort); runApp(const AvesApp()); diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index fcfa6ab36..5fdbc2e3d 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -6,6 +6,7 @@ enum ChipSetAction { // general sort, group, + map, select, selectAll, selectNone, @@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.collectionActionSelectAll; case ChipSetAction.selectNone: return context.l10n.collectionActionSelectNone; + case ChipSetAction.map: + return context.l10n.menuActionMap; case ChipSetAction.stats: return context.l10n.menuActionStats; case ChipSetAction.createAlbum: @@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction { case ChipSetAction.selectAll: case ChipSetAction.selectNone: return null; + case ChipSetAction.map: + return AIcons.map; case ChipSetAction.stats: return AIcons.stats; case ChipSetAction.createAlbum: diff --git a/lib/model/actions/collection_actions.dart b/lib/model/actions/collection_actions.dart index 360fd00b9..45aa9b110 100644 --- a/lib/model/actions/collection_actions.dart +++ b/lib/model/actions/collection_actions.dart @@ -5,6 +5,7 @@ enum CollectionAction { select, selectAll, selectNone, + map, stats, // apply to entry set copy, diff --git a/lib/model/actions/entry_actions.dart b/lib/model/actions/entry_actions.dart index b8e90c626..f54eecf70 100644 --- a/lib/model/actions/entry_actions.dart +++ b/lib/model/actions/entry_actions.dart @@ -19,6 +19,7 @@ enum EntryAction { // motion photo, viewMotionPhotoVideo, // external + copyToClipboard, edit, open, openMap, @@ -42,6 +43,7 @@ class EntryActions { EntryAction.delete, EntryAction.rename, EntryAction.export, + EntryAction.copyToClipboard, EntryAction.print, EntryAction.viewSource, EntryAction.viewMotionPhotoVideo, @@ -68,6 +70,8 @@ extension ExtraEntryAction on EntryAction { case EntryAction.toggleFavourite: // different data depending on toggle state return context.l10n.entryActionAddFavourite; + case EntryAction.copyToClipboard: + return context.l10n.entryActionCopyToClipboard; case EntryAction.delete: return context.l10n.entryActionDelete; case EntryAction.export: @@ -116,6 +120,8 @@ extension ExtraEntryAction on EntryAction { case EntryAction.toggleFavourite: // different data depending on toggle state return AIcons.favourite; + case EntryAction.copyToClipboard: + return AIcons.clipboard; case EntryAction.delete: return AIcons.delete; case EntryAction.export: diff --git a/lib/model/covers.dart b/lib/model/covers.dart index ffebf4b98..f6b84fee8 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -3,6 +3,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/services.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -77,10 +78,13 @@ class Covers with ChangeNotifier { } @immutable -class CoverRow { +class CoverRow extends Equatable { final CollectionFilter filter; final int contentId; + @override + List get props => [filter, contentId]; + const CoverRow({ required this.filter, required this.contentId, @@ -99,16 +103,4 @@ class CoverRow { 'filter': filter.toJson(), 'contentId': contentId, }; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is CoverRow && other.filter == filter && other.contentId == contentId; - } - - @override - int get hashCode => hashValues(filter, contentId); - - @override - String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}'; } diff --git a/lib/model/entry.dart b/lib/model/entry.dart index d3a3c3550..50a3f8a28 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; -import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; import 'package:country_code/country_code.dart'; @@ -382,13 +381,6 @@ class AvesEntry { LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; - String? get geoUri { - if (!hasGps) return null; - final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6); - final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6); - return 'geo:$latitude,$longitude?q=$latitude,$longitude'; - } - List? _xmpSubjects; List get xmpSubjects { diff --git a/lib/model/entry_images.dart b/lib/model/entry_images.dart index fc286bb8c..16a11be8b 100644 --- a/lib/model/entry_images.dart +++ b/lib/model/entry_images.dart @@ -10,6 +10,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; extension ExtraAvesEntry on AvesEntry { + bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent)); + ThumbnailProvider getThumbnail({double extent = 0}) { return ThumbnailProvider(_getThumbnailProviderKey(extent)); } diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index 524ad4be3..a64633507 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -1,7 +1,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/services/services.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; final Favourites favourites = Favourites._private(); @@ -62,10 +62,13 @@ class Favourites with ChangeNotifier { } @immutable -class FavouriteRow { +class FavouriteRow extends Equatable { final int contentId; final String path; + @override + List get props => [contentId, path]; + const FavouriteRow({ required this.contentId, required this.path, @@ -82,16 +85,4 @@ class FavouriteRow { 'contentId': contentId, 'path': path, }; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is FavouriteRow && other.contentId == contentId && other.path == path; - } - - @override - int get hashCode => hashValues(contentId, path); - - @override - String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}'; } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index 7e8c708e5..d7c8fab3f 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -16,6 +16,9 @@ class AlbumFilter extends CollectionFilter { final String album; final String? displayName; + @override + List get props => [album]; + const AlbumFilter(this.album, this.displayName); AlbumFilter.fromMap(Map json) @@ -78,16 +81,4 @@ class AlbumFilter extends CollectionFilter { // key `album-{path}` is expected by test driver @override String get key => '$type-$album'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is AlbumFilter && other.album == album; - } - - @override - int get hashCode => hashValues(type, album); - - @override - String toString() => '$runtimeType#${shortHash(this)}{album=$album}'; } diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 79c65a073..634b76264 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -10,6 +10,9 @@ class FavouriteFilter extends CollectionFilter { static const instance = FavouriteFilter._private(); + @override + List get props => []; + const FavouriteFilter._private(); @override @@ -37,13 +40,4 @@ class FavouriteFilter extends CollectionFilter { @override String get key => type; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is FavouriteFilter; - } - - @override - int get hashCode => type.hashCode; } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 33d9c9952..725085d3a 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -11,10 +11,12 @@ import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/utils/color_utils.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -abstract class CollectionFilter implements Comparable { +@immutable +abstract class CollectionFilter extends Equatable implements Comparable { static const List categoryOrder = [ QueryFilter.type, FavouriteFilter.type, @@ -88,20 +90,15 @@ abstract class CollectionFilter implements Comparable { } } -class FilterGridItem { +@immutable +class FilterGridItem with EquatableMixin { final T filter; final AvesEntry? entry; + @override + List get props => [filter, entry?.uri]; + const FilterGridItem(this.filter, this.entry); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is FilterGridItem && other.filter == filter && other.entry == entry; - } - - @override - int get hashCode => hashValues(filter, entry); } typedef EntryFilter = bool Function(AvesEntry); diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 6b4793275..01b9b1b71 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class LocationFilter extends CollectionFilter { @@ -10,14 +9,17 @@ class LocationFilter extends CollectionFilter { static const locationSeparator = ';'; final LocationLevel level; - String _location; - String? _countryCode; - late EntryFilter _test; + late final String _location; + late final String? _countryCode; + late final EntryFilter _test; - LocationFilter(this.level, this._location) { - final split = _location.split(locationSeparator); - if (split.isNotEmpty) _location = split[0]; - if (split.length > 1) _countryCode = split[1]; + @override + List get props => [level, _location, _countryCode]; + + LocationFilter(this.level, String location) { + final split = location.split(locationSeparator); + _location = split.isNotEmpty ? split[0] : location; + _countryCode = split.length > 1 ? split[1] : null; if (_location.isEmpty) { _test = (entry) => !entry.hasGps; @@ -75,18 +77,6 @@ class LocationFilter extends CollectionFilter { @override String get key => '$type-$level-$_location'; - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is LocationFilter && other.level == level && other._location == _location; - } - - @override - int get hashCode => hashValues(type, level, _location); - - @override - String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}'; - // U+0041 Latin Capital letter A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 750cdf0a4..9aba9d61f 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -3,20 +3,22 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class MimeFilter extends CollectionFilter { static const type = 'mime'; final String mime; - late EntryFilter _test; - late String _label; - late IconData _icon; + late final EntryFilter _test; + late final String _label; + late final IconData _icon; static final image = MimeFilter(MimeTypes.anyImage); static final video = MimeFilter(MimeTypes.anyVideo); + @override + List get props => [mime]; + MimeFilter(this.mime) { IconData? icon; var lowMime = mime.toLowerCase(); @@ -73,16 +75,4 @@ class MimeFilter extends CollectionFilter { @override String get key => '$type-$mime'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is MimeFilter && other.mime == mime; - } - - @override - int get hashCode => hashValues(type, mime); - - @override - String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}'; } diff --git a/lib/model/filters/path.dart b/lib/model/filters/path.dart index f4708579d..8ae97aeab 100644 --- a/lib/model/filters/path.dart +++ b/lib/model/filters/path.dart @@ -1,12 +1,13 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; class PathFilter extends CollectionFilter { static const type = 'path'; final String path; + @override + List get props => [path]; + const PathFilter(this.path); PathFilter.fromMap(Map json) @@ -31,16 +32,4 @@ class PathFilter extends CollectionFilter { @override String get key => '$type-$path'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is PathFilter && other.path == path; - } - - @override - int get hashCode => hashValues(type, path); - - @override - String toString() => '$runtimeType#${shortHash(this)}{path=$path}'; } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index c9ffc60a2..a166edf5a 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -12,7 +12,10 @@ class QueryFilter extends CollectionFilter { final String query; final bool colorful; - late EntryFilter _test; + late final EntryFilter _test; + + @override + List get props => [query]; QueryFilter(this.query, {this.colorful = true}) { var upQuery = query.toUpperCase(); @@ -63,16 +66,4 @@ class QueryFilter extends CollectionFilter { @override String get key => '$type-$query'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is QueryFilter && other.query == query; - } - - @override - int get hashCode => hashValues(type, query); - - @override - String toString() => '$runtimeType#${shortHash(this)}{query=$query}'; } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index c64fa04d2..1f5c1e0dc 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -1,14 +1,16 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class TagFilter extends CollectionFilter { static const type = 'tag'; final String tag; - late EntryFilter _test; + late final EntryFilter _test; + + @override + List get props => [tag]; TagFilter(this.tag) { if (tag.isEmpty) { @@ -49,16 +51,4 @@ class TagFilter extends CollectionFilter { @override String get key => '$type-$tag'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is TagFilter && other.tag == tag; - } - - @override - int get hashCode => hashValues(type, tag); - - @override - String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}'; } diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index ec0f51aeb..ff7d79b9b 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -1,7 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class TypeFilter extends CollectionFilter { @@ -14,8 +13,8 @@ class TypeFilter extends CollectionFilter { static const _sphericalVideo = 'spherical_video'; // subset of videos final String itemType; - late EntryFilter _test; - late IconData _icon; + late final EntryFilter _test; + late final IconData _icon; static final animated = TypeFilter._private(_animated); static final geotiff = TypeFilter._private(_geotiff); @@ -23,12 +22,19 @@ class TypeFilter extends CollectionFilter { static final panorama = TypeFilter._private(_panorama); static final sphericalVideo = TypeFilter._private(_sphericalVideo); + @override + List get props => [itemType]; + TypeFilter._private(this.itemType) { switch (itemType) { case _animated: _test = (entry) => entry.isAnimated; _icon = AIcons.animated; break; + case _geotiff: + _test = (entry) => entry.isGeotiff; + _icon = AIcons.geo; + break; case _motionPhoto: _test = (entry) => entry.isMotionPhoto; _icon = AIcons.motionPhoto; @@ -41,10 +47,6 @@ class TypeFilter extends CollectionFilter { _test = (entry) => entry.isVideo && entry.is360; _icon = AIcons.threeSixty; break; - case _geotiff: - _test = (entry) => entry.isGeotiff; - _icon = AIcons.geo; - break; } } @@ -91,16 +93,4 @@ class TypeFilter extends CollectionFilter { @override String get key => '$type-$itemType'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is TypeFilter && other.itemType == itemType; - } - - @override - int get hashCode => hashValues(type, itemType); - - @override - String toString() => '$runtimeType#${shortHash(this)}{itemType=$itemType}'; } diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 7813045cc..9393603e2 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -30,6 +30,8 @@ abstract class MetadataDb { Future updateEntryId(int oldId, AvesEntry entry); + Future> searchEntries(String query, {int? limit}); + // date taken Future clearDates(); @@ -235,6 +237,19 @@ class SqfliteMetadataDb implements MetadataDb { ); } + @override + Future> searchEntries(String query, {int? limit}) async { + final db = await _database; + final maps = await db.query( + entryTable, + where: 'title LIKE ?', + whereArgs: ['%$query%'], + orderBy: 'sourceDateTakenMillis DESC', + limit: limit, + ); + return maps.map((map) => AvesEntry.fromMap(map)).toSet(); + } + // date taken @override @@ -284,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb { await batch.commit(noResult: true); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); } catch (error, stack) { - debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack'); + debugPrint('$runtimeType failed to save metadata with error=$error\n$stack'); } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 7fea6d05b..229d292b5 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -13,7 +13,6 @@ import 'package:aves/services/services.dart'; import 'package:aves/utils/pedantic.dart'; import 'package:collection/collection.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -120,7 +119,7 @@ class Settings extends ChangeNotifier { // to allow settings customization without Firebase context (e.g. before a Flutter Driver test) Future initFirebase() async { await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled); - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled); + await reportService.setCollectionEnabled(isCrashlyticsEnabled); } Future reset({required bool includeInternalKeys}) async { diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index e11ad2012..55f43af2f 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -29,7 +29,8 @@ mixin AlbumMixin on SourceBase { void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); String getAlbumDisplayName(BuildContext? context, String dirPath) { - assert(!dirPath.endsWith(pContext.separator)); + final separator = pContext.separator; + assert(!dirPath.endsWith(separator)); if (context != null) { final type = androidFileUtils.getAlbumType(dirPath); @@ -52,8 +53,9 @@ mixin AlbumMixin on SourceBase { String unique(String dirPath, Set others) { final parts = pContext.split(dirPath); for (var i = parts.length - 1; i > 0; i--) { - final testName = pContext.joinAll(['', ...parts.skip(i)]); - if (others.every((item) => !item!.endsWith(testName))) return testName; + final name = pContext.joinAll(['', ...parts.skip(i)]); + final testName = '$separator$name'; + if (others.every((item) => !item!.endsWith(testName))) return name; } return dirPath; } diff --git a/lib/model/source/section_keys.dart b/lib/model/source/section_keys.dart index be43543ee..fd455cf02 100644 --- a/lib/model/source/section_keys.dart +++ b/lib/model/source/section_keys.dart @@ -1,41 +1,25 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; +@immutable class SectionKey { const SectionKey(); } -class EntryAlbumSectionKey extends SectionKey { +class EntryAlbumSectionKey extends SectionKey with EquatableMixin { final String? directory; + @override + List get props => [directory]; + const EntryAlbumSectionKey(this.directory); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is EntryAlbumSectionKey && other.directory == directory; - } - - @override - int get hashCode => directory.hashCode; - - @override - String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}'; } -class EntryDateSectionKey extends SectionKey { +class EntryDateSectionKey extends SectionKey with EquatableMixin { final DateTime? date; + @override + List get props => [date]; + const EntryDateSectionKey(this.date); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is EntryDateSectionKey && other.date == date; - } - - @override - int get hashCode => date.hashCode; - - @override - String toString() => '$runtimeType#${shortHash(this)}{date=$date}'; } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index 4b1f50d2e..d8b8dbf72 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -1,10 +1,12 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/math_utils.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:latlong2/latlong.dart'; class AndroidAppService { static const platform = MethodChannel('deckers.thibault/aves/app'); @@ -20,7 +22,7 @@ class AndroidAppService { } return packages; } on PlatformException catch (e) { - debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getPackages', e); } return {}; } @@ -33,11 +35,24 @@ class AndroidAppService { }); if (result != null) return result as Uint8List; } on PlatformException catch (e) { - debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getAppIcon', e); } return Uint8List(0); } + static Future copyToClipboard(String uri, String? label) async { + try { + final result = await platform.invokeMethod('copyToClipboard', { + 'uri': uri, + 'label': label, + }); + if (result != null) return result as bool; + } on PlatformException catch (e) { + await reportService.recordChannelError('copyToClipboard', e); + } + return false; + } + static Future edit(String uri, String mimeType) async { try { final result = await platform.invokeMethod('edit', { @@ -46,7 +61,7 @@ class AndroidAppService { }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('edit', e); } return false; } @@ -59,19 +74,23 @@ class AndroidAppService { }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('open', e); } return false; } - static Future openMap(String geoUri) async { + static Future openMap(LatLng latLng) async { + final latitude = roundToPrecision(latLng.latitude, decimals: 6); + final longitude = roundToPrecision(latLng.longitude, decimals: 6); + final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; + try { final result = await platform.invokeMethod('openMap', { 'geoUri': geoUri, }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('openMap', e); } return false; } @@ -84,7 +103,7 @@ class AndroidAppService { }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('setAs', e); } return false; } @@ -99,7 +118,7 @@ class AndroidAppService { }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('shareEntries', e); } return false; } @@ -113,7 +132,7 @@ class AndroidAppService { }); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('shareSingle', e); } return false; } diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index 257e79be2..ae063ac12 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -1,17 +1,57 @@ import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; class AndroidDebugService { static const platform = MethodChannel('deckers.thibault/aves/debug'); + static Future crash() async { + try { + await platform.invokeMethod('crash'); + } on PlatformException catch (e) { + await reportService.recordChannelError('crash', e); + } + } + + static Future exception() async { + try { + await platform.invokeMethod('exception'); + } on PlatformException catch (e) { + await reportService.recordChannelError('exception', e); + } + } + + static Future safeException() async { + try { + await platform.invokeMethod('safeException'); + } on PlatformException catch (e) { + await reportService.recordChannelError('safeException', e); + } + } + + static Future exceptionInCoroutine() async { + try { + await platform.invokeMethod('exceptionInCoroutine'); + } on PlatformException catch (e) { + await reportService.recordChannelError('exceptionInCoroutine', e); + } + } + + static Future safeExceptionInCoroutine() async { + try { + await platform.invokeMethod('safeExceptionInCoroutine'); + } on PlatformException catch (e) { + await reportService.recordChannelError('safeExceptionInCoroutine', e); + } + } + static Future getContextDirs() async { try { final result = await platform.invokeMethod('getContextDirs'); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getContextDirs', e); } return {}; } @@ -21,7 +61,7 @@ class AndroidDebugService { final result = await platform.invokeMethod('getEnv'); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getEnv', e); } return {}; } @@ -34,7 +74,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getBitmapFactoryInfo', e); } return {}; } @@ -48,7 +88,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getContentResolverMetadata', e); } return {}; } @@ -63,7 +103,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getExifInterfaceMetadata', e); } return {}; } @@ -76,7 +116,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e); } return {}; } @@ -91,7 +131,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getMetadataExtractorSummary', e); } return {}; } @@ -105,7 +145,7 @@ class AndroidDebugService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getTiffStructure', e); } return {}; } diff --git a/lib/services/app_shortcut_service.dart b/lib/services/app_shortcut_service.dart index 4ef9c5323..90fb1a102 100644 --- a/lib/services/app_shortcut_service.dart +++ b/lib/services/app_shortcut_service.dart @@ -24,7 +24,7 @@ class AppShortcutService { return result; } } on PlatformException catch (e) { - debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('canPin', e); } return false; } @@ -50,7 +50,7 @@ class AppShortcutService { 'filters': filters.map((filter) => filter.toJson()).toList(), }); } on PlatformException catch (e) { - debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('pin', e); } } } diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index 84a3fe8f1..82ba698f6 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -1,6 +1,5 @@ -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; class DeviceService { static const platform = MethodChannel('deckers.thibault/aves/device'); @@ -11,7 +10,7 @@ class DeviceService { final result = await platform.invokeMethod('getPerformanceClass'); if (result != null) return result as int; } on PlatformException catch (e) { - debugPrint('getPerformanceClass failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getPerformanceClass', e); } return 0; } diff --git a/lib/services/embedded_data_service.dart b/lib/services/embedded_data_service.dart index 50cd15adf..4a2276dfb 100644 --- a/lib/services/embedded_data_service.dart +++ b/lib/services/embedded_data_service.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; abstract class EmbeddedDataService { @@ -27,7 +27,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { }); if (result != null) return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getExifThumbnail', e); } return []; } @@ -43,7 +43,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('extractMotionPhotoVideo', e); } return {}; } @@ -57,7 +57,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('extractVideoEmbeddedPicture', e); } return {}; } @@ -75,7 +75,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('extractXmpDataProp', e); } return {}; } diff --git a/lib/services/geocoding_service.dart b/lib/services/geocoding_service.dart index c87baddf2..e993e64a5 100644 --- a/lib/services/geocoding_service.dart +++ b/lib/services/geocoding_service.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; class GeocodingService { @@ -21,7 +21,7 @@ class GeocodingService { }); return (result as List).cast().map((map) => Address.fromMap(map)).toList(); } on PlatformException catch (e) { - debugPrint('getAddress failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getAddress', e); } return []; } diff --git a/lib/services/global_search.dart b/lib/services/global_search.dart new file mode 100644 index 000000000..055af0e5c --- /dev/null +++ b/lib/services/global_search.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:aves/services/services.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; + +class GlobalSearch { + static const platform = MethodChannel('deckers.thibault/aves/global_search'); + + static Future registerCallback() async { + try { + await platform.invokeMethod('registerCallback', { + 'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(), + }); + } on PlatformException catch (e) { + await reportService.recordChannelError('registerCallback', e); + } + } +} + +Future _init() async { + WidgetsFlutterBinding.ensureInitialized(); + + // service initialization for path context, database + initPlatformServices(); + await metadataDb.init(); + + // `intl` initialization for date formatting + await initializeDateFormatting(); + + const _channel = MethodChannel('deckers.thibault/aves/global_search_background'); + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'getSuggestions': + return await _getSuggestions(call.arguments); + default: + throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}'); + } + }); + await _channel.invokeMethod('initialized'); +} + +Future>> _getSuggestions(dynamic args) async { + final suggestions = >[]; + if (args is Map) { + final query = args['query']; + final locale = args['locale']; + if (query is String && locale is String) { + final entries = await metadataDb.searchEntries(query, limit: 9); + suggestions.addAll(entries.map((entry) { + final date = entry.bestDate; + return { + 'data': entry.uri, + 'mimeType': entry.mimeType, + 'title': entry.bestTitle, + 'subtitle': date != null ? '${DateFormat.yMMMd(locale).format(date)} • ${DateFormat.Hm(locale).format(date)}' : null, + 'iconUri': entry.uri, + }; + })); + } + } + return suggestions; +} diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index b5461c602..870f42468 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -1,14 +1,15 @@ import 'dart:async'; import 'dart:math'; import 'dart:typed_data'; +import 'dart:ui'; import 'package:aves/model/entry.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/service_policy.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class ImageFileService { @@ -124,7 +125,7 @@ class PlatformImageFileService implements ImageFileService { }) as Map; return AvesEntry.fromMap(result); } on PlatformException catch (e) { - debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getEntry', e); } return null; } @@ -188,7 +189,7 @@ class PlatformImageFileService implements ImageFileService { ); return completer.future; } on PlatformException catch (e) { - debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + reportService.recordChannelError('getImage', e); } return Future.sync(() => Uint8List(0)); } @@ -223,7 +224,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return result as Uint8List; } on PlatformException catch (e) { - debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getRegion', e); } return Uint8List(0); }, @@ -260,7 +261,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return result as Uint8List; } on PlatformException catch (e) { - debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getThumbnail', e); } return Uint8List(0); }, @@ -274,7 +275,7 @@ class PlatformImageFileService implements ImageFileService { try { return platform.invokeMethod('clearSizedThumbnailDiskCache'); } on PlatformException catch (e) { - debugPrint('clearSizedThumbnailDiskCache failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('clearSizedThumbnailDiskCache', e); } } @@ -295,7 +296,7 @@ class PlatformImageFileService implements ImageFileService { 'entries': entries.map(_toPlatformEntryMap).toList(), }).map((event) => ImageOpEvent.fromMap(event)); } on PlatformException catch (e) { - debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + reportService.recordChannelError('delete', e); return Stream.error(e); } } @@ -314,7 +315,7 @@ class PlatformImageFileService implements ImageFileService { 'destinationPath': destinationAlbum, }).map((event) => MoveOpEvent.fromMap(event)); } on PlatformException catch (e) { - debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + reportService.recordChannelError('move', e); return Stream.error(e); } } @@ -333,7 +334,7 @@ class PlatformImageFileService implements ImageFileService { 'destinationPath': destinationAlbum, }).map((event) => ExportOpEvent.fromMap(event)); } on PlatformException catch (e) { - debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + reportService.recordChannelError('export', e); return Stream.error(e); } } @@ -356,7 +357,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { - debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('captureFrame', e); } return {}; } @@ -371,7 +372,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { - debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('rename', e); } return {}; } @@ -386,7 +387,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { - debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('rotate', e); } return {}; } @@ -400,7 +401,7 @@ class PlatformImageFileService implements ImageFileService { }); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { - debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('flip', e); } return {}; } diff --git a/lib/services/image_op_events.dart b/lib/services/image_op_events.dart index 6d172f8a1..e65f8f8da 100644 --- a/lib/services/image_op_events.dart +++ b/lib/services/image_op_events.dart @@ -1,11 +1,14 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; @immutable -class ImageOpEvent { +class ImageOpEvent extends Equatable { final bool success; final String uri; + @override + List get props => [success, uri]; + const ImageOpEvent({ required this.success, required this.uri, @@ -17,18 +20,6 @@ class ImageOpEvent { uri: map['uri'], ); } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ImageOpEvent && other.success == success && other.uri == uri; - } - - @override - int get hashCode => hashValues(success, uri); - - @override - String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}'; } class MoveOpEvent extends ImageOpEvent { @@ -55,6 +46,9 @@ class MoveOpEvent extends ImageOpEvent { class ExportOpEvent extends MoveOpEvent { final int? pageId; + @override + List get props => [success, uri, pageId]; + const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields}) : super( success: success, @@ -70,16 +64,4 @@ class ExportOpEvent extends MoveOpEvent { newFields: map['newFields'] ?? {}, ); } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId; - } - - @override - int get hashCode => hashValues(success, uri, pageId); - - @override - String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}'; } diff --git a/lib/services/media_store_service.dart b/lib/services/media_store_service.dart index acfe6b35d..32f3acf4f 100644 --- a/lib/services/media_store_service.dart +++ b/lib/services/media_store_service.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class MediaStoreService { @@ -26,7 +26,7 @@ class PlatformMediaStoreService implements MediaStoreService { }); return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('checkObsoleteContentIds', e); } return []; } @@ -39,7 +39,7 @@ class PlatformMediaStoreService implements MediaStoreService { }); return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('checkObsoletePaths failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('checkObsoletePaths', e); } return []; } @@ -51,7 +51,7 @@ class PlatformMediaStoreService implements MediaStoreService { 'knownEntries': knownEntries, }).map((event) => AvesEntry.fromMap(event)); } on PlatformException catch (e) { - debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + reportService.recordChannelError('getEntries', e); return Stream.error(e); } } diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index e42fdbf64..258e34676 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -3,7 +3,7 @@ import 'package:aves/model/metadata.dart'; import 'package:aves/model/multipage.dart'; import 'package:aves/model/panorama.dart'; import 'package:aves/services/service_policy.dart'; -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; abstract class MetadataService { @@ -36,7 +36,7 @@ class PlatformMetadataService implements MetadataService { }); if (result != null) return result as Map; } on PlatformException catch (e) { - debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getAllMetadata', e); } return {}; } @@ -66,7 +66,7 @@ class PlatformMetadataService implements MetadataService { result['contentId'] = entry.contentId; return CatalogMetadata.fromMap(result); } on PlatformException catch (e) { - debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getCatalogMetadata', e); } return null; } @@ -92,7 +92,7 @@ class PlatformMetadataService implements MetadataService { }) as Map; return OverlayMetadata.fromMap(result); } on PlatformException catch (e) { - debugPrint('getOverlayMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getOverlayMetadata', e); } return null; } @@ -114,7 +114,7 @@ class PlatformMetadataService implements MetadataService { } return MultiPageInfo.fromPageMaps(entry, pageMaps); } on PlatformException catch (e) { - debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getMultiPageInfo', e); } return null; } @@ -132,7 +132,7 @@ class PlatformMetadataService implements MetadataService { }) as Map; return PanoramaInfo.fromMap(result); } on PlatformException catch (e) { - debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('PanoramaInfo', e); } return null; } @@ -146,7 +146,7 @@ class PlatformMetadataService implements MetadataService { 'prop': prop, }); } on PlatformException catch (e) { - debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getContentResolverProp', e); } return null; } diff --git a/lib/services/report_service.dart b/lib/services/report_service.dart new file mode 100644 index 000000000..feab952a2 --- /dev/null +++ b/lib/services/report_service.dart @@ -0,0 +1,55 @@ +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +abstract class ReportService { + bool get isCollectionEnabled; + + Future setCollectionEnabled(bool enabled); + + Future log(String message); + + Future setCustomKey(String key, Object value); + + Future setCustomKeys(Map map); + + Future recordError(dynamic exception, StackTrace? stack); + + Future recordFlutterError(FlutterErrorDetails flutterErrorDetails); + + Future recordChannelError(String method, PlatformException e) { + return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null); + } +} + +class CrashlyticsReportService extends ReportService { + FirebaseCrashlytics get instance => FirebaseCrashlytics.instance; + + @override + bool get isCollectionEnabled => instance.isCrashlyticsCollectionEnabled; + + @override + Future setCollectionEnabled(bool enabled) => instance.setCrashlyticsCollectionEnabled(enabled); + + @override + Future log(String message) => instance.log(message); + + @override + Future setCustomKey(String key, Object value) => instance.setCustomKey(key, value); + + @override + Future setCustomKeys(Map map) { + final _instance = instance; + return Future.forEach>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value)); + } + + @override + Future recordError(dynamic exception, StackTrace? stack) { + return instance.recordError(exception, stack); + } + + @override + Future recordFlutterError(FlutterErrorDetails flutterErrorDetails) { + return instance.recordFlutterError(flutterErrorDetails); + } +} diff --git a/lib/services/services.dart b/lib/services/services.dart index 04c0feebf..52fbd7b2d 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -4,6 +4,7 @@ import 'package:aves/services/embedded_data_service.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/report_service.dart'; import 'package:aves/services/storage_service.dart'; import 'package:aves/services/time_service.dart'; import 'package:aves/services/window_service.dart'; @@ -20,6 +21,7 @@ final EmbeddedDataService embeddedDataService = getIt(); final ImageFileService imageFileService = getIt(); final MediaStoreService mediaStoreService = getIt(); final MetadataService metadataService = getIt(); +final ReportService reportService = getIt(); final StorageService storageService = getIt(); final TimeService timeService = getIt(); final WindowService windowService = getIt(); @@ -33,6 +35,7 @@ void initPlatformServices() { getIt.registerLazySingleton(() => PlatformImageFileService()); getIt.registerLazySingleton(() => PlatformMediaStoreService()); getIt.registerLazySingleton(() => PlatformMetadataService()); + getIt.registerLazySingleton(() => CrashlyticsReportService()); getIt.registerLazySingleton(() => PlatformStorageService()); getIt.registerLazySingleton(() => PlatformTimeService()); getIt.registerLazySingleton(() => PlatformWindowService()); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 8e1574ccc..53d460f5f 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:aves/services/output_buffer.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -47,7 +48,7 @@ class PlatformStorageService implements StorageService { final result = await platform.invokeMethod('getStorageVolumes'); return (result as List).cast().map((map) => StorageVolume.fromMap(map)).toSet(); } on PlatformException catch (e) { - debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getStorageVolumes', e); } return {}; } @@ -60,7 +61,7 @@ class PlatformStorageService implements StorageService { }); return result as int?; } on PlatformException catch (e) { - debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getFreeSpace', e); } return null; } @@ -71,7 +72,7 @@ class PlatformStorageService implements StorageService { final result = await platform.invokeMethod('getGrantedDirectories'); return (result as List).cast(); } on PlatformException catch (e) { - debugPrint('getGrantedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getGrantedDirectories', e); } return []; } @@ -83,7 +84,7 @@ class PlatformStorageService implements StorageService { 'path': path, }); } on PlatformException catch (e) { - debugPrint('revokeDirectoryAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('revokeDirectoryAccess', e); } return; } @@ -98,7 +99,7 @@ class PlatformStorageService implements StorageService { return (result as List).cast().map(VolumeRelativeDirectory.fromMap).toSet(); } } on PlatformException catch (e) { - debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getInaccessibleDirectories', e); } return {}; } @@ -111,7 +112,7 @@ class PlatformStorageService implements StorageService { return (result as List).cast().map(VolumeRelativeDirectory.fromMap).toSet(); } } on PlatformException catch (e) { - debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('getRestrictedDirectories', e); } return {}; } @@ -134,7 +135,7 @@ class PlatformStorageService implements StorageService { ); return completer.future; } on PlatformException catch (e) { - debugPrint('requestVolumeAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('requestVolumeAccess', e); } return false; } @@ -148,7 +149,7 @@ class PlatformStorageService implements StorageService { }); if (result != null) return result as int; } on PlatformException catch (e) { - debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('deleteEmptyDirectories', e); } return 0; } @@ -164,7 +165,7 @@ class PlatformStorageService implements StorageService { }); if (result != null) return Uri.tryParse(result); } on PlatformException catch (e) { - debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('scanFile', e); } return null; } @@ -172,7 +173,7 @@ class PlatformStorageService implements StorageService { @override Future createFile(String name, String mimeType, Uint8List bytes) async { try { - final completer = Completer(); + final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ 'op': 'createFile', 'name': name, @@ -188,7 +189,7 @@ class PlatformStorageService implements StorageService { ); return completer.future; } on PlatformException catch (e) { - debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('createFile', e); } return false; } @@ -215,7 +216,7 @@ class PlatformStorageService implements StorageService { ); return completer.future; } on PlatformException catch (e) { - debugPrint('openFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('openFile', e); } return Uint8List(0); } @@ -223,7 +224,7 @@ class PlatformStorageService implements StorageService { @override Future selectDirectory() async { try { - final completer = Completer(); + final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ 'op': 'selectDirectory', }).listen( @@ -236,7 +237,7 @@ class PlatformStorageService implements StorageService { ); return completer.future; } on PlatformException catch (e) { - debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + await reportService.recordChannelError('selectDirectory', e); } return null; } diff --git a/lib/services/time_service.dart b/lib/services/time_service.dart index af9b90613..bc0ea606c 100644 --- a/lib/services/time_service.dart +++ b/lib/services/time_service.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; abstract class TimeService { @@ -13,7 +13,7 @@ class PlatformTimeService implements TimeService { try { return await platform.invokeMethod('getDefaultTimeZone'); } on PlatformException catch (e) { - debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getDefaultTimeZone', e); } return null; } diff --git a/lib/services/viewer_service.dart b/lib/services/viewer_service.dart index 336ebd99b..0a3bfa089 100644 --- a/lib/services/viewer_service.dart +++ b/lib/services/viewer_service.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; class ViewerService { @@ -10,7 +10,7 @@ class ViewerService { final result = await platform.invokeMethod('getIntentData'); if (result != null) return (result as Map).cast(); } on PlatformException catch (e) { - debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('getIntentData', e); } return {}; } @@ -21,7 +21,7 @@ class ViewerService { 'uri': uri, }); } on PlatformException catch (e) { - debugPrint('pick failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('pick', e); } } } diff --git a/lib/services/window_service.dart b/lib/services/window_service.dart index 6f461bcd2..cf204de4b 100644 --- a/lib/services/window_service.dart +++ b/lib/services/window_service.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -24,7 +24,7 @@ class PlatformWindowService implements WindowService { 'on': on, }); } on PlatformException catch (e) { - debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('keepScreenOn', e); } } @@ -34,7 +34,7 @@ class PlatformWindowService implements WindowService { final result = await platform.invokeMethod('isRotationLocked'); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('isRotationLocked failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('isRotationLocked', e); } return false; } @@ -62,7 +62,7 @@ class PlatformWindowService implements WindowService { 'orientation': orientationCode, }); } on PlatformException catch (e) { - debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('requestOrientation', e); } } @@ -72,7 +72,7 @@ class PlatformWindowService implements WindowService { final result = await platform.invokeMethod('canSetCutoutMode'); if (result != null) return result as bool; } on PlatformException catch (e) { - debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('canSetCutoutMode', e); } return false; } @@ -84,7 +84,7 @@ class PlatformWindowService implements WindowService { 'use': use, }); } on PlatformException catch (e) { - debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + await reportService.recordChannelError('setCutoutMode', e); } } } diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 135e39dd8..2edc3b3bf 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart'; class Durations { // Flutter animations (with margin) static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` + // page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation` + static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin` static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute` static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 423dd0d92..656334fe7 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -36,6 +36,7 @@ class AIcons { 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 debug = Icons.whatshot_outlined; static const IconData delete = Icons.delete_outlined; @@ -49,6 +50,7 @@ class AIcons { static const IconData import = MdiIcons.fileImportOutline; static const IconData info = Icons.info_outlined; static const IconData layers = Icons.layers_outlined; + static const IconData map = Icons.map_outlined; 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; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index d38ae562f..7ae61645c 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -3,13 +3,14 @@ import 'package:aves/services/services.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { - late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; + late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; Set storageVolumes = {}; Set _packages = {}; List _potentialAppDirs = []; @@ -21,9 +22,10 @@ class AndroidFileUtils { AndroidFileUtils._private(); Future init() async { + separator = pContext.separator; storageVolumes = await storageService.getStorageVolumes(); - // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' - primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? '/'; + primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator; + // standard dcimPath = pContext.join(primaryStorage, 'DCIM'); downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); @@ -38,11 +40,11 @@ class AndroidFileUtils { appNameChangeNotifier.notifyListeners(); } - bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO')); + bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO')); - bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('Screenshots'); + bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots'); - bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords')); + bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('${separator}Screen recordings') || path.endsWith('${separator}ScreenRecords')); bool isVideoCapturesPath(String path) => path == videoCapturesPath; @@ -53,7 +55,7 @@ class AndroidFileUtils { final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path)); // storage volume path includes trailing '/', but argument path may or may not, // which is an issue when the path is at the root - return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/'); + return volume != null || path.endsWith(separator) ? volume : getStorageVolume('$path$separator'); } bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; @@ -155,9 +157,12 @@ class StorageVolume { } @immutable -class VolumeRelativeDirectory { +class VolumeRelativeDirectory extends Equatable { final String volumePath, relativeDir; + @override + List get props => [volumePath, relativeDir]; + const VolumeRelativeDirectory({ required this.volumePath, required this.relativeDir, @@ -187,13 +192,4 @@ class VolumeRelativeDirectory { final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath); return volume?.getDescription(context) ?? volumePath; } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is VolumeRelativeDirectory && other.volumePath == volumePath && other.relativeDir == relativeDir; - } - - @override - int get hashCode => hashValues(volumePath, relativeDir); } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 583c64f86..fbc713a6d 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -44,7 +44,6 @@ class Constants { Dependency( name: 'AndroidSVG', license: 'Apache 2.0', - licenseUrl: 'https://github.com/BigBadaboom/androidsvg/blob/master/LICENSE', sourceUrl: 'https://github.com/BigBadaboom/androidsvg', ), Dependency( @@ -56,19 +55,16 @@ class Constants { Dependency( name: 'CWAC-Document', license: 'Apache 2.0', - licenseUrl: 'https://github.com/commonsguy/cwac-document/blob/master/LICENSE', sourceUrl: 'https://github.com/commonsguy/cwac-document', ), Dependency( name: 'Glide', license: 'Apache 2.0, BSD 2-Clause', - licenseUrl: 'https://github.com/bumptech/glide/blob/master/LICENSE', sourceUrl: 'https://github.com/bumptech/glide', ), Dependency( name: 'Metadata Extractor', license: 'Apache 2.0', - licenseUrl: 'https://github.com/drewnoakes/metadata-extractor/blob/master/LICENSE', sourceUrl: 'https://github.com/drewnoakes/metadata-extractor', ), ]; @@ -77,49 +73,44 @@ class Constants { Dependency( name: 'Connectivity Plus', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/LICENSE', + licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus', ), Dependency( name: 'FlutterFire (Core, Crashlytics)', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE', sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', ), Dependency( name: 'fijkplayer (Aves fork)', license: 'MIT', - licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE', sourceUrl: 'https://github.com/deckerst/fijkplayer', ), Dependency( name: 'Google API Availability', license: 'MIT', - licenseUrl: 'https://github.com/Baseflow/flutter-google-api-availability/blob/master/LICENSE', sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability', ), Dependency( name: 'Google Maps for Flutter', license: 'BSD 3-Clause', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter', + sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter', ), Dependency( name: 'Package Info Plus', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/LICENSE', + licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus', ), Dependency( name: 'Permission Handler', license: 'MIT', - licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', ), Dependency( name: 'Printing', license: 'Apache 2.0', - licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), Dependency( @@ -130,21 +121,19 @@ class Constants { ), Dependency( name: 'sqflite', - license: 'MIT', - licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE', + license: 'BSD 2-Clause', sourceUrl: 'https://github.com/tekartik/sqflite', ), Dependency( name: 'Streams Channel (Aves fork)', license: 'Apache 2.0', - licenseUrl: 'https://github.com/deckerst/aves_streams_channel/blob/master/LICENSE', sourceUrl: 'https://github.com/deckerst/aves_streams_channel', ), Dependency( name: 'URL Launcher', license: 'BSD 3-Clause', licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', + sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher', ), ]; @@ -152,37 +141,36 @@ class Constants { Dependency( name: 'Charts', license: 'Apache 2.0', - licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE', sourceUrl: 'https://github.com/google/charts', ), + Dependency( + name: 'Custom rounded rectangle border', + license: 'MIT', + sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border', + ), Dependency( name: 'Decorated Icon', license: 'MIT', - licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE', sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', ), Dependency( name: 'Expansion Tile Card (Aves fork)', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE', sourceUrl: 'https://github.com/deckerst/expansion_tile_card', ), Dependency( name: 'FlexColorPicker', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/rydmike/flex_color_picker/blob/master/LICENSE', sourceUrl: 'https://github.com/rydmike/flex_color_picker', ), Dependency( name: 'Flutter Highlight', license: 'MIT', - licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE', sourceUrl: 'https://github.com/git-touch/highlight', ), Dependency( name: 'Flutter Map', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE', sourceUrl: 'https://github.com/fleaflet/flutter_map', ), Dependency( @@ -194,19 +182,16 @@ class Constants { Dependency( name: 'Flutter Staggered Animations', license: 'MIT', - licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE', sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', ), Dependency( name: 'Material Design Icons Flutter', license: 'MIT', - licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE', sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter', ), Dependency( name: 'Overlay Support', license: 'Apache 2.0', - licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE', sourceUrl: 'https://github.com/boyan01/overlay_support', ), Dependency( @@ -218,19 +203,16 @@ class Constants { Dependency( name: 'Panorama', license: 'Apache 2.0', - licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE', sourceUrl: 'https://github.com/zesage/panorama', ), Dependency( name: 'Percent Indicator', license: 'BSD 2-Clause', - licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE', - sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/', + sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator', ), Dependency( name: 'Provider', license: 'MIT', - licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE', sourceUrl: 'https://github.com/rrousselGit/provider', ), ]; @@ -239,21 +221,28 @@ class Constants { Dependency( name: 'Collection', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE', sourceUrl: 'https://github.com/dart-lang/collection', ), Dependency( name: 'Country Code', license: 'MIT', - licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE', sourceUrl: 'https://github.com/denixport/dart.country', ), + Dependency( + name: 'Equatable', + license: 'MIT', + sourceUrl: 'https://github.com/felangel/equatable', + ), Dependency( name: 'Event Bus', license: 'MIT', - licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE', sourceUrl: 'https://github.com/marcojakob/dart-event-bus', ), + Dependency( + name: 'Fluster', + license: 'MIT', + sourceUrl: 'https://github.com/alfonsocejudo/fluster', + ), Dependency( name: 'Flutter Lints', license: 'BSD 3-Clause', @@ -263,49 +252,41 @@ class Constants { Dependency( name: 'Get It', license: 'MIT', - licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE', sourceUrl: 'https://github.com/fluttercommunity/get_it', ), Dependency( name: 'Github', license: 'MIT', - licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE', sourceUrl: 'https://github.com/SpinlockLabs/github.dart', ), Dependency( name: 'Intl', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE', sourceUrl: 'https://github.com/dart-lang/intl', ), Dependency( name: 'LatLong2', license: 'Apache 2.0', - licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE', sourceUrl: 'https://github.com/jifalops/dart-latlong', ), Dependency( name: 'PDF for Dart and Flutter', license: 'Apache 2.0', - licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE', sourceUrl: 'https://github.com/DavBfr/dart_pdf', ), Dependency( name: 'Tuple', license: 'BSD 2-Clause', - licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE', sourceUrl: 'https://github.com/dart-lang/tuple', ), Dependency( name: 'Version', license: 'BSD 3-Clause', - licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE', sourceUrl: 'https://github.com/dartninja/version', ), Dependency( name: 'XML', license: 'MIT', - licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE', sourceUrl: 'https://github.com/renggli/dart-xml', ), ]; @@ -320,7 +301,7 @@ class Dependency { const Dependency({ required this.name, required this.license, - required this.licenseUrl, + String? licenseUrl, required this.sourceUrl, - }); + }) : licenseUrl = licenseUrl ?? '$sourceUrl/blob/master/LICENSE'; } diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 3a432e0eb..6a142abe3 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -15,8 +15,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/welcome_page.dart'; +import 'package:equatable/equatable.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -43,6 +43,7 @@ class _AvesAppState extends State { List _navigatorObservers = []; final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); + final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage(); @@ -50,10 +51,11 @@ class _AvesAppState extends State { @override void initState() { super.initState(); - initPlatformServices(); + EquatableConfig.stringify = true; _appSetup = _setup(); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); + _errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)); } @override @@ -122,18 +124,17 @@ class _AvesAppState extends State { Future _setup() async { await Firebase.initializeApp().then((app) { - final crashlytics = FirebaseCrashlytics.instance; - FlutterError.onError = crashlytics.recordFlutterError; - crashlytics.setCustomKey('locales', window.locales.join(', ')); + FlutterError.onError = reportService.recordFlutterError; final now = DateTime.now(); - crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})'); - crashlytics.setCustomKey( - 'build_mode', - kReleaseMode - ? 'release' - : kProfileMode - ? 'profile' - : 'debug'); + reportService.setCustomKeys({ + 'locales': window.locales.join(', '), + 'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})', + 'build_mode': kReleaseMode + ? 'release' + : kProfileMode + ? 'profile' + : 'debug', + }); }); await settings.init(); await settings.initFirebase(); @@ -148,7 +149,7 @@ class _AvesAppState extends State { // do not reset when relaunching the app if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; - FirebaseCrashlytics.instance.log('New intent'); + reportService.log('New intent'); _navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( settings: const RouteSettings(name: HomePage.routeName), builder: (_) => getFirstPage(intentData: intentData), @@ -169,4 +170,6 @@ class _AvesAppState extends State { }); } } + + void _onError(String? error) => reportService.recordError(error, null); } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 3caadebec..22d2f2f00 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -22,9 +22,10 @@ import 'package:aves/widgets/common/basic/menu_row.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.dart'; +import 'package:aves/widgets/stats/stats_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -212,6 +213,11 @@ class _CollectionAppBarState extends State with SingleTickerPr 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, @@ -292,6 +298,9 @@ class _CollectionAppBarState extends State with SingleTickerPr case CollectionAction.selectNone: context.read>().clearSelection(); break; + case CollectionAction.map: + _goToMap(); + break; case CollectionAction.stats: _goToStats(); break; @@ -377,6 +386,19 @@ class _CollectionAppBarState extends State with SingleTickerPr ); } + void _goToMap() { + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: MapPage.routeName), + builder: (context) => MapPage( + source: source, + parentCollection: collection, + ), + ), + ); + } + void _goToStats() { Navigator.push( context, diff --git a/lib/widgets/collection/thumbnail/image.dart b/lib/widgets/collection/thumbnail/image.dart index b768bf4de..20965d397 100644 --- a/lib/widgets/collection/thumbnail/image.dart +++ b/lib/widgets/collection/thumbnail/image.dart @@ -19,6 +19,7 @@ import 'package:provider/provider.dart'; class ThumbnailImage extends StatefulWidget { final AvesEntry entry; final double extent; + final bool progressive; final BoxFit? fit; final bool showLoadingBackground; final ValueNotifier? cancellableNotifier; @@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget { Key? key, required this.entry, required this.extent, + this.progressive = true, this.fit, this.showLoadingBackground = true, this.cancellableNotifier, @@ -93,7 +95,7 @@ class _ThumbnailImageState extends State { _lastException = null; _providers.clear(); _providers.addAll([ - if (!entry.isSvg) + if (widget.progressive && !entry.isSvg) _ConditionalImageProvider( ScrollAwareImageProvider( context: _scrollAwareContext, diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 7b6ece415..7ca088ad7 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -72,6 +72,9 @@ class _ReportOverlayState extends State> with SingleTickerPr Stream get opStream => widget.opStream; + static const radius = 160.0; + static const strokeWidth = 16.0; + @override void initState() { super.initState(); @@ -104,40 +107,55 @@ class _ReportOverlayState extends State> with SingleTickerPr @override Widget build(BuildContext context) { + final progressColor = Theme.of(context).accentColor; return AbsorbPointer( child: StreamBuilder( - stream: opStream, - builder: (context, snapshot) { - final processedCount = processed.length.toDouble(); - final total = widget.itemCount; - assert(processedCount <= total); - final percent = min(1.0, processedCount / total); - return FadeTransition( - opacity: _animation, - child: Container( - decoration: const BoxDecoration( - gradient: RadialGradient( - colors: [ - Colors.black, - Colors.black54, - ], - ), - ), - child: Center( - child: CircularPercentIndicator( - percent: percent, - lineWidth: 16, - radius: 160, - backgroundColor: Colors.white24, - progressColor: Theme.of(context).accentColor, - animation: true, - center: Text(NumberFormat.percentPattern().format(percent)), - animateFromLastPercent: true, - ), + stream: opStream, + builder: (context, snapshot) { + final processedCount = processed.length.toDouble(); + final total = widget.itemCount; + assert(processedCount <= total); + final percent = min(1.0, processedCount / total); + return FadeTransition( + opacity: _animation, + child: Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + colors: [ + Colors.black, + Colors.black54, + ], ), ), - ); - }), + child: Center( + child: Stack( + children: [ + Container( + width: radius, + height: radius, + padding: const EdgeInsets.all(strokeWidth / 2), + child: CircularProgressIndicator( + color: progressColor.withOpacity(.1), + strokeWidth: strokeWidth, + ), + ), + CircularPercentIndicator( + percent: percent, + lineWidth: strokeWidth, + radius: radius, + backgroundColor: Colors.white24, + progressColor: progressColor, + animation: true, + center: Text(NumberFormat.percentPattern().format(percent)), + animateFromLastPercent: true, + ), + ], + ), + ), + ), + ); + }, + ), ); } } diff --git a/lib/widgets/common/behaviour/route_tracker.dart b/lib/widgets/common/behaviour/route_tracker.dart index c0d577919..2e3d53fd0 100644 --- a/lib/widgets/common/behaviour/route_tracker.dart +++ b/lib/widgets/common/behaviour/route_tracker.dart @@ -1,18 +1,18 @@ -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/material.dart'; class CrashlyticsRouteTracker extends NavigatorObserver { @override - void didPush(Route route, Route? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPush to ${_name(route)}'); + void didPush(Route route, Route? previousRoute) => reportService.log('Nav didPush to ${_name(route)}'); @override - void didPop(Route route, Route? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPop to ${_name(previousRoute)}'); + void didPop(Route route, Route? previousRoute) => reportService.log('Nav didPop to ${_name(previousRoute)}'); @override - void didRemove(Route route, Route? previousRoute) => FirebaseCrashlytics.instance.log('Nav didRemove to ${_name(previousRoute)}'); + void didRemove(Route route, Route? previousRoute) => reportService.log('Nav didRemove to ${_name(previousRoute)}'); @override - void didReplace({Route? newRoute, Route? oldRoute}) => FirebaseCrashlytics.instance.log('Nav didReplace to ${_name(newRoute)}'); + void didReplace({Route? newRoute, Route? oldRoute}) => reportService.log('Nav didReplace to ${_name(newRoute)}'); String _name(Route? route) => route?.settings.name ?? 'unnamed ${route?.runtimeType}'; } diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index 9b95ceffa..7f8d141bd 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -221,13 +222,17 @@ class SectionedListLayout { String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}'; } -class SectionLayout { +@immutable +class SectionLayout extends Equatable { final SectionKey sectionKey; final int firstIndex, lastIndex, bodyFirstIndex; final double minOffset, maxOffset, bodyMinOffset; final double headerExtent, tileExtent, spacing, mainAxisStride; final IndexedWidgetBuilder builder; + @override + List get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing]; + const SectionLayout({ required this.sectionKey, required this.firstIndex, @@ -263,15 +268,6 @@ class SectionLayout { if (scrollOffset < 0) return firstIndex; return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; } - - @override - bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing; - - @override - int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing); - - @override - String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}'; } class _GridRow extends MultiChildRenderObjectWidget { diff --git a/lib/widgets/common/magnifier/controller/state.dart b/lib/widgets/common/magnifier/controller/state.dart index 949c692c0..ee8313d13 100644 --- a/lib/widgets/common/magnifier/controller/state.dart +++ b/lib/widgets/common/magnifier/controller/state.dart @@ -1,28 +1,22 @@ import 'dart:ui'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; @immutable -class MagnifierState { - const MagnifierState({ - required this.position, - required this.scale, - required this.source, - }); - +class MagnifierState extends Equatable { final Offset position; final double? scale; final ChangeSource source; @override - bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale; + List get props => [position, scale, source]; - @override - int get hashCode => hashValues(position, scale, source); - - @override - String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}'; + const MagnifierState({ + required this.position, + required this.scale, + required this.source, + }); } enum ChangeSource { internal, gesture, animation } diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 7ea5035e3..a84051e1c 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/common/magnifier/magnifier.dart'; import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; /// Internal widget in which controls all animations lifecycle, core responses @@ -276,17 +277,21 @@ class _MagnifierCoreState extends State with TickerProviderStateM } } -class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { +@immutable +class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate with EquatableMixin { + final Size subjectSize; + final Alignment basePosition; + final bool applyScale; + + @override + List get props => [subjectSize, basePosition, applyScale]; + const _CenterWithOriginalSizeDelegate( this.subjectSize, this.basePosition, this.applyScale, ); - final Size subjectSize; - final Alignment basePosition; - final bool applyScale; - @override Offset getPositionForChild(Size size, Size childSize) { final childWidth = applyScale ? subjectSize.width : childSize.width; @@ -309,10 +314,4 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) { return oldDelegate != this; } - - @override - bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale; - - @override - int get hashCode => hashValues(subjectSize, basePosition, applyScale); } diff --git a/lib/widgets/common/magnifier/scale/scale_boundaries.dart b/lib/widgets/common/magnifier/scale/scale_boundaries.dart index 3c9db4994..85e54b0a8 100644 --- a/lib/widgets/common/magnifier/scale/scale_boundaries.dart +++ b/lib/widgets/common/magnifier/scale/scale_boundaries.dart @@ -2,17 +2,22 @@ import 'dart:ui'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; /// Internal class to wrap custom scale boundaries (min, max and initial) /// Also, stores values regarding the two sizes: the container and the child. -class ScaleBoundaries { +@immutable +class ScaleBoundaries extends Equatable { final ScaleLevel _minScale; final ScaleLevel _maxScale; final ScaleLevel _initialScale; final Size viewportSize; final Size childSize; + @override + List get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize]; + const ScaleBoundaries({ required ScaleLevel minScale, required ScaleLevel maxScale, @@ -57,13 +62,4 @@ class ScaleBoundaries { Offset childToStatePosition(double scale, Offset childPosition) { return (_childCenter - childPosition) * scale; } - - @override - bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize; - - @override - int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize); - - @override - String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}'; } diff --git a/lib/widgets/common/magnifier/scale/scale_level.dart b/lib/widgets/common/magnifier/scale/scale_level.dart index ac7b5b1a4..a076181ae 100644 --- a/lib/widgets/common/magnifier/scale/scale_level.dart +++ b/lib/widgets/common/magnifier/scale/scale_level.dart @@ -1,12 +1,17 @@ import 'dart:math'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; -class ScaleLevel { +@immutable +class ScaleLevel extends Equatable { final ScaleReference ref; final double factor; + @override + List get props => [ref, factor]; + const ScaleLevel({ this.ref = ScaleReference.absolute, this.factor = 1.0, @@ -15,18 +20,6 @@ class ScaleLevel { static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height); static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height); - - @override - String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}'; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ScaleLevel && other.ref == ref && other.factor == factor; - } - - @override - int get hashCode => hashValues(ref, factor); } enum ScaleReference { absolute, contained, covered } diff --git a/lib/widgets/common/magnifier/scale/state.dart b/lib/widgets/common/magnifier/scale/state.dart index 1a587bb50..fed627b22 100644 --- a/lib/widgets/common/magnifier/scale/state.dart +++ b/lib/widgets/common/magnifier/scale/state.dart @@ -1,29 +1,24 @@ import 'dart:ui'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class ScaleStateChange { - const ScaleStateChange({ - required this.state, - required this.source, - this.childFocalPoint, - }); - +class ScaleStateChange extends Equatable { final ScaleState state; final ChangeSource source; final Offset? childFocalPoint; @override - bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint; + List get props => [state, source, childFocalPoint]; - @override - int get hashCode => hashValues(state, source, childFocalPoint); - - @override - String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}'; + const ScaleStateChange({ + required this.state, + required this.source, + this.childFocalPoint, + }); } enum ScaleState { diff --git a/lib/widgets/common/map/attribution.dart b/lib/widgets/common/map/attribution.dart new file mode 100644 index 000000000..6ea0210f3 --- /dev/null +++ b/lib/widgets/common/map/attribution.dart @@ -0,0 +1,47 @@ +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class Attribution extends StatelessWidget { + final EntryMapStyle style; + + const Attribution({ + Key? key, + required this.style, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + switch (style) { + case EntryMapStyle.osmHot: + return _buildAttributionMarkdown(context, context.l10n.mapAttributionOsmHot); + case EntryMapStyle.stamenToner: + case EntryMapStyle.stamenWatercolor: + return _buildAttributionMarkdown(context, context.l10n.mapAttributionStamen); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildAttributionMarkdown(BuildContext context, String data) { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: MarkdownBody( + data: data, + selectable: true, + styleSheet: MarkdownStyleSheet( + a: TextStyle(color: Theme.of(context).accentColor), + p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize), + ), + onTapLink: (text, href, title) async { + if (href != null && await canLaunch(href)) { + await launch(href); + } + }, + ), + ); + } +} diff --git a/lib/widgets/viewer/info/maps/common.dart b/lib/widgets/common/map/buttons.dart similarity index 75% rename from lib/widgets/viewer/info/maps/common.dart rename to lib/widgets/common/map/buttons.dart index 429343e83..febc07549 100644 --- a/lib/widgets/viewer/info/maps/common.dart +++ b/lib/widgets/common/map/buttons.dart @@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; - -class MapDecorator extends StatelessWidget { - final Widget? child; - - static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles - static const mapBackground = Color(0xFFDBD5D3); - static const mapLoadingGrid = Color(0xFFC4BEBB); - - const MapDecorator({ - Key? key, - this.child, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onScaleStart: (details) { - // absorb scale gesture here to prevent scrolling - // and triggering by mistake a move to the image page above - }, - child: ClipRRect( - borderRadius: mapBorderRadius, - child: Container( - color: mapBackground, - height: 200, - child: Stack( - children: [ - const GridPaper( - color: mapLoadingGrid, - interval: 10, - divisions: 1, - subdivisions: 1, - child: CustomPaint( - size: Size.infinite, - ), - ), - if (child != null) child!, - ], - ), - ), - ), - ); - } -} +import 'package:latlong2/latlong.dart'; class MapButtonPanel extends StatelessWidget { - final String geoUri; - final void Function(double amount) zoomBy; + final LatLng latLng; + final Future Function(double amount)? zoomBy; static const double padding = 4; const MapButtonPanel({ Key? key, - required this.geoUri, - required this.zoomBy, + required this.latLng, + this.zoomBy, }) : super(key: key); @override @@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget { children: [ MapOverlayButton( icon: AIcons.openOutside, - onPressed: () => AndroidAppService.openMap(geoUri).then((success) { + onPressed: () => AndroidAppService.openMap(latLng).then((success) { if (!success) showNoMatchingAppDialog(context); }), tooltip: context.l10n.entryActionOpenMap, @@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget { const Spacer(), MapOverlayButton( icon: AIcons.zoomIn, - onPressed: () => zoomBy(1), + onPressed: zoomBy != null ? () => zoomBy!(1) : null, tooltip: context.l10n.viewerInfoMapZoomInTooltip, ), const SizedBox(height: padding), MapOverlayButton( icon: AIcons.zoomOut, - onPressed: () => zoomBy(-1), + onPressed: zoomBy != null ? () => zoomBy!(-1) : null, tooltip: context.l10n.viewerInfoMapZoomOutTooltip, ), ], @@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget { class MapOverlayButton extends StatelessWidget { final IconData icon; final String tooltip; - final VoidCallback onPressed; + final VoidCallback? onPressed; const MapOverlayButton({ Key? key, diff --git a/lib/widgets/common/map/decorator.dart b/lib/widgets/common/map/decorator.dart new file mode 100644 index 000000000..58aedd0e2 --- /dev/null +++ b/lib/widgets/common/map/decorator.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class MapDecorator extends StatelessWidget { + final bool interactive; + final Widget? child; + + static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles + static const mapBackground = Color(0xFFDBD5D3); + static const mapLoadingGrid = Color(0xFFC4BEBB); + + const MapDecorator({ + Key? key, + required this.interactive, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onScaleStart: interactive + ? null + : (details) { + // absorb scale gesture here to prevent scrolling + // and triggering by mistake a move to the image page above + }, + child: ClipRRect( + borderRadius: mapBorderRadius, + child: Container( + color: mapBackground, + child: Stack( + children: [ + const GridPaper( + color: mapLoadingGrid, + interval: 10, + divisions: 1, + subdivisions: 1, + child: CustomPaint( + size: Size.infinite, + ), + ), + if (child != null) child!, + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/map/geo_entry.dart b/lib/widgets/common/map/geo_entry.dart new file mode 100644 index 000000000..9eab3b881 --- /dev/null +++ b/lib/widgets/common/map/geo_entry.dart @@ -0,0 +1,41 @@ +import 'package:aves/model/entry.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/foundation.dart'; + +class GeoEntry extends Clusterable { + AvesEntry? entry; + + GeoEntry({ + this.entry, + double? latitude, + double? longitude, + bool? isCluster = false, + int? clusterId, + int? pointsSize, + String? markerId, + String? childMarkerId, + }) : super( + latitude: latitude, + longitude: longitude, + isCluster: isCluster, + clusterId: clusterId, + pointsSize: pointsSize, + markerId: markerId, + childMarkerId: childMarkerId, + ); + + factory GeoEntry.createCluster(BaseCluster cluster, double longitude, double latitude) { + return GeoEntry( + latitude: latitude, + longitude: longitude, + isCluster: cluster.isCluster, + clusterId: cluster.id, + pointsSize: cluster.pointsSize, + markerId: cluster.id.toString(), + childMarkerId: cluster.childMarkerId, + ); + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{isCluster=$isCluster, lat=$latitude, lng=$longitude, clusterId=$clusterId, pointsSize=$pointsSize, markerId=$markerId, childMarkerId=$childMarkerId}'; +} diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart new file mode 100644 index 000000000..ba2c26270 --- /dev/null +++ b/lib/widgets/common/map/geo_map.dart @@ -0,0 +1,202 @@ +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/widgets/common/map/attribution.dart'; +import 'package:aves/widgets/common/map/buttons.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:equatable/equatable.dart'; +import 'package:fluster/fluster.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class GeoMap extends StatefulWidget { + final List entries; + final bool interactive; + final double? mapHeight; + final ValueNotifier isAnimatingNotifier; + final UserZoomChangeCallback? onUserZoomChange; + + static const markerImageExtent = 48.0; + static const pointerSize = Size(8, 6); + + const GeoMap({ + Key? key, + required this.entries, + required this.interactive, + this.mapHeight, + required this.isAnimatingNotifier, + this.onUserZoomChange, + }) : super(key: key); + + @override + _GeoMapState createState() => _GeoMapState(); +} + +class _GeoMapState extends State with TickerProviderStateMixin { + // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking + // cf https://github.com/flutter/flutter/issues/28493 + // it is especially severe the first time, but still significant afterwards + // so we prevent loading it while scrolling or animating + bool _googleMapsLoaded = false; + late ValueNotifier boundsNotifier; + + List get entries => widget.entries; + + bool get interactive => widget.interactive; + + double? get mapHeight => widget.mapHeight; + + @override + void initState() { + super.initState(); + boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints( + points: entries.map((v) => v.latLng!).toSet(), + collocationZoom: settings.infoMapZoom, + )); + } + + @override + Widget build(BuildContext context) { + final markers = entries.map((entry) { + var latLng = entry.latLng!; + return GeoEntry( + entry: entry, + latitude: latLng.latitude, + longitude: latLng.longitude, + markerId: entry.uri, + ); + }).toList(); + final markerCluster = Fluster( + // we keep clustering on the whole range of zooms (including the maximum) + // to avoid collocated entries overlapping + minZoom: 0, + maxZoom: 22, + // TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent? + // (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9) + radius: 240, + extent: 2 << 9, + nodeSize: 64, + points: markers, + createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), + ); + + return FutureBuilder( + future: availability.isConnected, + builder: (context, snapshot) { + if (snapshot.data != true) return const SizedBox(); + return Selector( + selector: (context, s) => s.infoMapStyle, + builder: (context, mapStyle, child) { + final isGoogleMaps = mapStyle.isGoogleMaps; + final progressive = !isGoogleMaps; + Widget _buildMarker(MarkerKey key) => ImageMarker( + key: key, + entry: key.entry, + count: key.count, + extent: GeoMap.markerImageExtent, + pointerSize: GeoMap.pointerSize, + progressive: progressive, + ); + + Widget child = isGoogleMaps + ? EntryGoogleMap( + boundsNotifier: boundsNotifier, + interactive: interactive, + style: mapStyle, + markerBuilder: _buildMarker, + markerCluster: markerCluster, + markerEntries: entries, + onUserZoomChange: widget.onUserZoomChange, + ) + : EntryLeafletMap( + boundsNotifier: boundsNotifier, + interactive: interactive, + style: mapStyle, + markerBuilder: _buildMarker, + markerCluster: markerCluster, + markerEntries: entries, + markerSize: Size( + GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, + GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height, + ), + onUserZoomChange: widget.onUserZoomChange, + ); + + child = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + mapHeight != null + ? SizedBox( + height: mapHeight, + child: child, + ) + : Expanded(child: child), + Attribution(style: mapStyle), + ], + ); + + return AnimatedSize( + alignment: Alignment.topCenter, + curve: Curves.easeInOutCubic, + duration: Durations.mapStyleSwitchAnimation, + vsync: this, + child: ValueListenableBuilder( + valueListenable: widget.isAnimatingNotifier, + builder: (context, animating, child) { + if (!animating && isGoogleMaps) { + _googleMapsLoaded = true; + } + Widget replacement = Stack( + children: [ + MapDecorator( + interactive: interactive, + ), + MapButtonPanel( + latLng: boundsNotifier.value.center, + ), + ], + ); + if (mapHeight != null) { + replacement = SizedBox( + height: mapHeight, + child: replacement, + ); + } + return Visibility( + visible: !isGoogleMaps || _googleMapsLoaded, + replacement: replacement, + child: child!, + ); + }, + child: child, + ), + ); + }, + ); + }, + ); + } +} + +@immutable +class MarkerKey extends LocalKey with EquatableMixin { + final AvesEntry entry; + final int? count; + + @override + List get props => [entry, count]; + + const MarkerKey(this.entry, this.count); +} + +typedef EntryMarkerBuilder = Widget Function(MarkerKey key); +typedef UserZoomChangeCallback = void Function(double zoom); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart new file mode 100644 index 000000000..922c7c458 --- /dev/null +++ b/lib/widgets/common/map/google/map.dart @@ -0,0 +1,224 @@ +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/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 ValueNotifier boundsNotifier; + final bool interactive; + final EntryMapStyle style; + final EntryMarkerBuilder markerBuilder; + final Fluster markerCluster; + final List markerEntries; + final UserZoomChangeCallback? onUserZoomChange; + + const EntryGoogleMap({ + Key? key, + required this.boundsNotifier, + required this.interactive, + required this.style, + required this.markerBuilder, + required this.markerCluster, + required this.markerEntries, + this.onUserZoomChange, + }) : super(key: key); + + @override + State createState() => _EntryGoogleMapState(); +} + +class _EntryGoogleMapState extends State with WidgetsBindingObserver { + GoogleMapController? _controller; + final Map _markerBitmaps = {}; + final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); + + ValueNotifier get boundsNotifier => widget.boundsNotifier; + + ZoomedBounds get bounds => boundsNotifier.value; + + static const uninitializedLatLng = LatLng(0, 0); + + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(covariant EntryGoogleMap oldWidget) { + super.didUpdateWidget(oldWidget); + const eq = DeepCollectionEquality(); + if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) { + _markerBitmaps.clear(); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + break; + case AppLifecycleState.resumed: + // workaround for blank Google Maps when resuming app + // cf https://github.com/flutter/flutter/issues/40284 + _controller?.setMapStyle(null); + break; + } + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, visibleRegion, child) { + final allEntries = widget.markerEntries; + final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + if (v.isCluster!) { + final uri = v.childMarkerId; + final entry = allEntries.firstWhere((v) => v.uri == uri); + return MapEntry(MarkerKey(entry, v.pointsSize), v); + } + return MapEntry(MarkerKey(v.entry!, null), v); + })); + + return Stack( + children: [ + MarkerGeneratorWidget( + markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(), + isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), + onRendered: (key, bitmap) { + _markerBitmaps[key] = bitmap; + _markerBitmapChangeNotifier.notifyListeners(); + }, + ), + MapDecorator( + interactive: widget.interactive, + child: _buildMap(clusterByMarkerKey), + ), + MapButtonPanel( + latLng: bounds.center, + zoomBy: _zoomBy, + ), + ], + ); + }, + ); + } + + Widget _buildMap(Map clusterByMarkerKey) { + return AnimatedBuilder( + animation: _markerBitmapChangeNotifier, + builder: (context, child) { + final markers = {}; + clusterByMarkerKey.forEach((markerKey, cluster) { + final bytes = _markerBitmaps[markerKey]; + if (bytes != null) { + final latLng = LatLng(cluster.latitude!, cluster.longitude!); + markers.add(Marker( + markerId: MarkerId(cluster.markerId!), + icon: BitmapDescriptor.fromBytes(bytes), + position: latLng, + )); + } + }); + + final interactive = widget.interactive; + return GoogleMap( + initialCameraPosition: CameraPosition( + target: _toGoogleLatLng(bounds.center), + zoom: bounds.zoom, + ), + onMapCreated: (controller) { + _controller = controller; + controller.getZoomLevel().then(_updateVisibleRegion); + setState(() {}); + }, + // TODO TLAD [map] add common compass button for both google/leaflet + compassEnabled: false, + mapToolbarEnabled: false, + mapType: _toMapType(widget.style), + // TODO TLAD [map] allow rotation when leaflet scale layer is fixed + rotateGesturesEnabled: false, + scrollGesturesEnabled: interactive, + // zoom controls disabled to use provider agnostic controls + zoomControlsEnabled: false, + zoomGesturesEnabled: interactive, + // lite mode disabled because it lacks camera animation + liteModeEnabled: false, + // tilt disabled to match leaflet + tiltGesturesEnabled: false, + myLocationEnabled: false, + myLocationButtonEnabled: false, + markers: markers, + onCameraMove: (position) => _updateVisibleRegion(position.zoom), + ); + }, + ); + } + + Future _updateVisibleRegion(double zoom) async { + final bounds = await _controller?.getVisibleRegion(); + if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { + boundsNotifier.value = ZoomedBounds( + west: bounds.southwest.longitude, + south: bounds.southwest.latitude, + east: bounds.northeast.longitude, + north: bounds.northeast.latitude, + zoom: zoom, + ); + } 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); + }); + } + } + + Future _zoomBy(double amount) async { + final controller = _controller; + if (controller == null) return; + + widget.onUserZoomChange?.call(await controller.getZoomLevel() + amount); + await controller.animateCamera(CameraUpdate.zoomBy(amount)); + } + + // `LatLng` used by `google_maps_flutter` is not the one from `latlong2` package + LatLng _toGoogleLatLng(ll.LatLng latLng) => LatLng(latLng.latitude, latLng.longitude); + + MapType _toMapType(EntryMapStyle style) { + switch (style) { + case EntryMapStyle.googleNormal: + return MapType.normal; + case EntryMapStyle.googleHybrid: + return MapType.hybrid; + case EntryMapStyle.googleTerrain: + return MapType.terrain; + default: + return MapType.none; + } + } +} diff --git a/lib/widgets/common/map/google/marker_generator.dart b/lib/widgets/common/map/google/marker_generator.dart new file mode 100644 index 000000000..bc572cad0 --- /dev/null +++ b/lib/widgets/common/map/google/marker_generator.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +// generate bitmap from widget, for Google Maps +class MarkerGeneratorWidget extends StatefulWidget { + final List markers; + final bool Function(T markerKey) isReadyToRender; + final void Function(T markerKey, Uint8List bitmap) onRendered; + + const MarkerGeneratorWidget({ + Key? key, + required this.markers, + required this.isReadyToRender, + required this.onRendered, + }) : super(key: key); + + @override + _MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState(); +} + +class _MarkerGeneratorWidgetState extends State> { + final Set<_MarkerGeneratorItem> _items = {}; + + @override + void initState() { + super.initState(); + _checkNextFrame(); + } + + @override + void didUpdateWidget(covariant MarkerGeneratorWidget oldWidget) { + super.didUpdateWidget(oldWidget); + widget.markers.forEach((markerWidget) { + final item = getOrCreate(markerWidget.key as T); + item.globalKey = GlobalKey(); + }); + _checkNextFrame(); + } + + void _checkNextFrame() { + WidgetsBinding.instance!.addPostFrameCallback((_) async { + if (!mounted) return; + final waitingItems = _items.where((v) => v.isWaiting).toSet(); + final readyItems = waitingItems.where((v) => widget.isReadyToRender(v.markerKey)).toSet(); + readyItems.forEach((v) async { + final bitmap = await v.render(); + if (bitmap != null) { + widget.onRendered(v.markerKey, bitmap); + } + }); + if (readyItems.length < waitingItems.length) { + _checkNextFrame(); + setState(() {}); + } + }); + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: Offset(context.select((mq) => mq.size.width), 0), + child: Material( + type: MaterialType.transparency, + child: Stack( + children: _items.map((item) { + return RepaintBoundary( + key: item.globalKey, + child: widget.markers.firstWhereOrNull((v) => v.key == item.markerKey) ?? const SizedBox(), + ); + }).toList(), + ), + ), + ); + } + + _MarkerGeneratorItem getOrCreate(T markerKey) { + final existingItem = _items.firstWhereOrNull((v) => v.markerKey == markerKey); + if (existingItem != null) return existingItem; + + final newItem = _MarkerGeneratorItem(markerKey); + _items.add(newItem); + return newItem; + } +} + +enum MarkerGeneratorItemState { waiting, rendering, done } + +class _MarkerGeneratorItem { + final T markerKey; + GlobalKey? globalKey; + MarkerGeneratorItemState state = MarkerGeneratorItemState.waiting; + + _MarkerGeneratorItem(this.markerKey); + + bool get isWaiting => state == MarkerGeneratorItemState.waiting; + + Future render() async { + Uint8List? bytes; + final _globalKey = globalKey; + if (_globalKey != null) { + state = MarkerGeneratorItemState.rendering; + final boundary = _globalKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + if (boundary.hasSize && boundary.size != Size.zero) { + final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + bytes = byteData?.buffer.asUint8List(); + } + state = bytes != null ? MarkerGeneratorItemState.done : MarkerGeneratorItemState.waiting; + } + return bytes; + } + + @override + String toString() => '$runtimeType#${shortHash(this)}{markerKey=$markerKey, globalKey=$globalKey, state=$state}'; +} diff --git a/lib/widgets/common/map/latlng_tween.dart b/lib/widgets/common/map/latlng_tween.dart new file mode 100644 index 000000000..912e14179 --- /dev/null +++ b/lib/widgets/common/map/latlng_tween.dart @@ -0,0 +1,16 @@ +import 'package:aves/widgets/common/map/latlng_utils.dart'; +import 'package:flutter/widgets.dart'; +import 'package:latlong2/latlong.dart'; + +class LatLngTween extends Tween { + LatLngTween({ + required LatLng? begin, + required LatLng? end, + }) : super( + begin: begin, + end: end, + ); + + @override + LatLng? lerp(double t) => LatLngUtils.lerp(begin, end, t); +} diff --git a/lib/widgets/common/map/latlng_utils.dart b/lib/widgets/common/map/latlng_utils.dart new file mode 100644 index 000000000..35867b4ec --- /dev/null +++ b/lib/widgets/common/map/latlng_utils.dart @@ -0,0 +1,14 @@ +import 'package:latlong2/latlong.dart'; + +class LatLngUtils { + static LatLng? lerp(LatLng? a, LatLng? b, double t) { + if (a == null && b == null) return null; + + final _a = a ?? LatLng(0, 0); + final _b = b ?? LatLng(0, 0); + return LatLng( + _a.latitude + (_b.latitude - _a.latitude) * t, + _a.longitude + (_b.longitude - _a.longitude) * t, + ); + } +} diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart new file mode 100644 index 000000000..51ad8e06f --- /dev/null +++ b/lib/widgets/common/map/leaflet/map.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/enums.dart'; +import 'package:aves/widgets/common/map/buttons.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/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 ValueNotifier boundsNotifier; + final bool interactive; + final EntryMapStyle style; + final EntryMarkerBuilder markerBuilder; + final Fluster markerCluster; + final List markerEntries; + final Size markerSize; + final UserZoomChangeCallback? onUserZoomChange; + + const EntryLeafletMap({ + Key? key, + required this.boundsNotifier, + required this.interactive, + required this.style, + required this.markerBuilder, + required this.markerCluster, + required this.markerEntries, + required this.markerSize, + this.onUserZoomChange, + }) : super(key: key); + + @override + State createState() => _EntryLeafletMapState(); +} + +class _EntryLeafletMapState extends State with TickerProviderStateMixin { + final MapController _mapController = MapController(); + final List _subscriptions = []; + + ValueNotifier get boundsNotifier => widget.boundsNotifier; + + ZoomedBounds get bounds => boundsNotifier.value; + + // 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; + + @override + void initState() { + super.initState(); + _subscriptions.add(_mapController.mapEventStream.listen((event) => _updateVisibleRegion())); + WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion()); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: boundsNotifier, + builder: (context, visibleRegion, child) { + final allEntries = widget.markerEntries; + final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + if (v.isCluster!) { + final uri = v.childMarkerId; + final entry = allEntries.firstWhere((v) => v.uri == uri); + return MapEntry(MarkerKey(entry, v.pointsSize), v); + } + return MapEntry(MarkerKey(v.entry!, null), v); + })); + + return Stack( + children: [ + MapDecorator( + interactive: widget.interactive, + child: _buildMap(clusterByMarkerKey), + ), + MapButtonPanel( + latLng: bounds.center, + zoomBy: _zoomBy, + ), + ], + ); + }, + ); + } + + Widget _buildMap(Map clusterByMarkerKey) { + final markerSize = widget.markerSize; + final markers = clusterByMarkerKey.entries.map((kv) { + final markerKey = kv.key; + final cluster = kv.value; + final latLng = LatLng(cluster.latitude!, cluster.longitude!); + return Marker( + point: latLng, + builder: (context) => GestureDetector( + onTap: () => _moveTo(latLng), + child: widget.markerBuilder(markerKey), + ), + width: markerSize.width, + height: markerSize.height, + anchorPos: AnchorPos.align(AnchorAlign.top), + ); + }).toList(); + + return FlutterMap( + options: MapOptions( + center: bounds.center, + zoom: bounds.zoom, + interactiveFlags: widget.interactive ? interactiveFlags : InteractiveFlag.none, + ), + mapController: _mapController, + children: [ + _buildMapLayer(), + ScaleLayerWidget( + options: ScaleLayerOptions(), + ), + MarkerLayerWidget( + options: MarkerLayerOptions( + markers: markers, + rotate: true, + rotateAlignment: Alignment.bottomCenter, + ), + ), + ], + ); + } + + Widget _buildMapLayer() { + switch (widget.style) { + case EntryMapStyle.osmHot: + return const OSMHotLayer(); + case EntryMapStyle.stamenToner: + return const StamenTonerLayer(); + case EntryMapStyle.stamenWatercolor: + return const StamenWatercolorLayer(); + default: + return const SizedBox.shrink(); + } + } + + void _updateVisibleRegion() { + final bounds = _mapController.bounds; + if (bounds != null) { + boundsNotifier.value = ZoomedBounds( + west: bounds.west, + south: bounds.south, + east: bounds.east, + north: bounds.north, + zoom: _mapController.zoom, + ); + } + } + + Future _zoomBy(double amount) async { + final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax); + widget.onUserZoomChange?.call(endZoom); + + final zoomTween = Tween(begin: _mapController.zoom, end: endZoom); + await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation))); + } + + Future _moveTo(LatLng point) async { + final centerTween = LatLngTween(begin: _mapController.center, end: point); + await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom)); + } + + Future _animateCamera(void Function(Animation animation) animate) async { + final controller = AnimationController(duration: _cameraAnimationDuration, vsync: this); + final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + controller.addListener(() => animate(animation)); + animation.addStatusListener((status) { + if (status == AnimationStatus.completed) { + controller.dispose(); + } else if (status == AnimationStatus.dismissed) { + controller.dispose(); + } + }); + await controller.forward(); + } +} diff --git a/lib/widgets/viewer/info/maps/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart similarity index 94% rename from lib/widgets/viewer/info/maps/scale_layer.dart rename to lib/widgets/common/map/leaflet/scale_layer.dart index 30489bc8a..4a97aa3c1 100644 --- a/lib/widgets/viewer/info/maps/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -1,12 +1,11 @@ import 'dart:math'; import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/plugin_api.dart'; -import 'scalebar_utils.dart' as util; - class ScaleLayerOptions extends LayerOptions { final Widget Function(double width, String distance) builder; @@ -24,6 +23,7 @@ class ScaleLayerOptions extends LayerOptions { } } +// TODO TLAD [map] scale bar should not rotate together with map layer class ScaleLayerWidget extends StatelessWidget { final ScaleLayerOptions options; @@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget { : 2); final distance = scale[max(0, min(20, level))].toDouble(); final start = map.project(center); - final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance); + final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance); final end = map.project(targetPoint); final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m'; final width = end.x - (start.x as double); diff --git a/lib/widgets/common/map/leaflet/scalebar_utils.dart b/lib/widgets/common/map/leaflet/scalebar_utils.dart new file mode 100644 index 000000000..b3d852241 --- /dev/null +++ b/lib/widgets/common/map/leaflet/scalebar_utils.dart @@ -0,0 +1,119 @@ +import 'dart:math'; + +import 'package:aves/utils/math_utils.dart'; +import 'package:latlong2/latlong.dart'; + +class ScaleBarUtils { + static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { + var mSemiMajorAxis = 6378137.0; //WGS84 major axis + var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; + var mFlattening = 1.0 / 298.257223563; + // double mInverseFlattening = 298.257223563; + + var a = mSemiMajorAxis; + var b = mSemiMinorAxis; + var aSquared = a * a; + var bSquared = b * b; + var f = mFlattening; + var phi1 = toRadians(start.latitude); + var alpha1 = toRadians(startBearing); + var cosAlpha1 = cos(alpha1); + var sinAlpha1 = sin(alpha1); + var s = distance; + var tanU1 = (1.0 - f) * tan(phi1); + var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); + var sinU1 = tanU1 * cosU1; + + // eq. 1 + var sigma1 = atan2(tanU1, cosAlpha1); + + // eq. 2 + var sinAlpha = cosU1 * sinAlpha1; + + var sin2Alpha = sinAlpha * sinAlpha; + var cos2Alpha = 1 - sin2Alpha; + var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared; + + // eq. 3 + var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared))); + + // eq. 4 + var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared))); + + // iterate until there is a negligible change in sigma + double deltaSigma; + var sOverbA = s / (b * A); + var sigma = sOverbA; + double sinSigma; + var prevSigma = sOverbA; + double sigmaM2; + double cosSigmaM2; + double cos2SigmaM2; + + for (;;) { + // eq. 5 + sigmaM2 = 2.0 * sigma1 + sigma; + cosSigmaM2 = cos(sigmaM2); + cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; + sinSigma = sin(sigma); + var cosSignma = cos(sigma); + + // eq. 6 + deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2))); + + // eq. 7 + sigma = sOverbA + deltaSigma; + + // break after converging to tolerance + if ((sigma - prevSigma).abs() < 0.0000000000001) break; + + prevSigma = sigma; + } + + sigmaM2 = 2.0 * sigma1 + sigma; + cosSigmaM2 = cos(sigmaM2); + cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; + + var cosSigma = cos(sigma); + sinSigma = sin(sigma); + + // eq. 8 + var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0))); + + // eq. 9 + // This fixes the pole crossing defect spotted by Matt Feemster. When a + // path passes a pole and essentially crosses a line of latitude twice - + // once in each direction - the longitude calculation got messed up. + // Using + // atan2 instead of atan fixes the defect. The change is in the next 3 + // lines. + // double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 * + // sinSigma * cosAlpha1); + // double lambda = Math.atan(tanLambda); + var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1)); + + // eq. 10 + var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha)); + + // eq. 11 + var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2))); + + // eq. 12 + // double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 * + // cosSigma * cosAlpha1); + + // build result + var latitude = toDegrees(phi2); + var longitude = start.longitude + toDegrees(L); + + // if ((endBearing != null) && (endBearing.length > 0)) { + // endBearing[0] = toDegrees(alpha2); + // } + + latitude = latitude < -90 ? -90 : latitude; + latitude = latitude > 90 ? 90 : latitude; + longitude = longitude < -180 ? -180 : longitude; + longitude = longitude > 180 ? 180 : longitude; + return LatLng(latitude, longitude); + } +} diff --git a/lib/widgets/common/map/leaflet/tile_layers.dart b/lib/widgets/common/map/leaflet/tile_layers.dart new file mode 100644 index 000000000..4a2649d0c --- /dev/null +++ b/lib/widgets/common/map/leaflet/tile_layers.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:provider/provider.dart'; + +class OSMHotLayer extends StatelessWidget { + const OSMHotLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', + subdomains: ['a', 'b', 'c'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} + +class StamenTonerLayer extends StatelessWidget { + const StamenTonerLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', + subdomains: ['a', 'b', 'c', 'd'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} + +class StamenWatercolorLayer extends StatelessWidget { + const StamenWatercolorLayer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TileLayerWidget( + options: TileLayerOptions( + urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', + subdomains: ['a', 'b', 'c', 'd'], + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, + ), + ); + } +} diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/common/map/marker.dart similarity index 55% rename from lib/widgets/viewer/info/maps/marker.dart rename to lib/widgets/common/map/marker.dart index 516b174ce..c8e781c80 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -1,16 +1,15 @@ -import 'dart:typed_data'; -import 'dart:ui' as ui; - import 'package:aves/model/entry.dart'; import 'package:aves/widgets/collection/thumbnail/image.dart'; +import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:provider/provider.dart'; class ImageMarker extends StatelessWidget { - final AvesEntry entry; + final AvesEntry? entry; + final int? count; final double extent; final Size pointerSize; + final bool progressive; static const double outerBorderRadiusDim = 8; static const double outerBorderWidth = 1.5; @@ -18,21 +17,27 @@ class ImageMarker extends StatelessWidget { static const outerBorderColor = Colors.white30; static const innerBorderColor = Color(0xFF212121); static const outerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim)); - static const innerBorderRadius = BorderRadius.all(Radius.circular(outerBorderRadiusDim - outerBorderWidth)); + static const innerRadius = Radius.circular(outerBorderRadiusDim - outerBorderWidth); + static const innerBorderRadius = BorderRadius.all(innerRadius); const ImageMarker({ Key? key, required this.entry, + required this.count, required this.extent, - this.pointerSize = Size.zero, + required this.pointerSize, + required this.progressive, }) : super(key: key); @override Widget build(BuildContext context) { - Widget child = ThumbnailImage( - entry: entry, - extent: extent, - ); + Widget child = entry != null + ? ThumbnailImage( + entry: entry!, + extent: extent, + progressive: progressive, + ) + : const SizedBox(); // need to be sized for the Google Maps marker generator child = SizedBox( @@ -57,6 +62,49 @@ class ImageMarker extends StatelessWidget { borderRadius: innerBorderRadius, ); + child = DecoratedBox( + decoration: innerDecoration, + position: DecorationPosition.foreground, + child: ClipRRect( + borderRadius: innerBorderRadius, + child: child, + ), + ); + + if (count != null) { + const borderSide = BorderSide( + color: innerBorderColor, + width: innerBorderWidth, + ); + child = Stack( + children: [ + child, + Container( + padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 2), + decoration: ShapeDecoration( + color: Theme.of(context).accentColor, + shape: const CustomRoundedRectangleBorder( + leftSide: borderSide, + rightSide: borderSide, + topSide: borderSide, + bottomSide: borderSide, + topLeftCornerSide: borderSide, + bottomRightCornerSide: borderSide, + borderRadius: BorderRadius.only( + topLeft: innerRadius, + bottomRight: innerRadius, + ), + ), + ), + child: Text( + '$count', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ); + } + return CustomPaint( foregroundPainter: MarkerPointerPainter( color: innerBorderColor, @@ -68,14 +116,7 @@ class ImageMarker extends StatelessWidget { padding: EdgeInsets.only(bottom: pointerSize.height), child: Container( decoration: outerDecoration, - child: DecoratedBox( - decoration: innerDecoration, - position: DecorationPosition.foreground, - child: ClipRRect( - borderRadius: innerBorderRadius, - child: child, - ), - ), + child: child, ), ), ); @@ -124,65 +165,3 @@ class MarkerPointerPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } - -// generate bitmap from widget, for Google Maps -class MarkerGeneratorWidget extends StatefulWidget { - final List markers; - final Duration delay; - final Function(List bitmaps) onComplete; - - const MarkerGeneratorWidget({ - Key? key, - required this.markers, - this.delay = Duration.zero, - required this.onComplete, - }) : super(key: key); - - @override - _MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState(); -} - -class _MarkerGeneratorWidgetState extends State { - final _globalKeys = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance!.addPostFrameCallback((_) async { - if (widget.delay > Duration.zero) { - await Future.delayed(widget.delay); - } - widget.onComplete(await _getBitmaps(context)); - }); - } - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: Offset(context.select((mq) => mq.size.width), 0), - child: Material( - type: MaterialType.transparency, - child: Stack( - children: widget.markers.map((i) { - final key = GlobalKey(debugLabel: 'map-marker-$i'); - _globalKeys.add(key); - return RepaintBoundary( - key: key, - child: i, - ); - }).toList(), - ), - ), - ); - } - - Future> _getBitmaps(BuildContext context) async { - final pixelRatio = context.read().devicePixelRatio; - return Future.wait(_globalKeys.map((key) async { - final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary; - final image = await boundary.toImage(pixelRatio: pixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - return byteData != null ? byteData.buffer.asUint8List() : Uint8List(0); - })); - } -} diff --git a/lib/widgets/common/map/zoomed_bounds.dart b/lib/widgets/common/map/zoomed_bounds.dart new file mode 100644 index 000000000..a19f42e5f --- /dev/null +++ b/lib/widgets/common/map/zoomed_bounds.dart @@ -0,0 +1,64 @@ +import 'dart:math'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; + +@immutable +class ZoomedBounds extends Equatable { + final double west, south, east, north, zoom; + + List get boundingBox => [west, south, east, north]; + + LatLng get center => LatLng((north + south) / 2, (east + west) / 2); + + @override + List get props => [west, south, east, north, zoom]; + + const ZoomedBounds({ + required this.west, + required this.south, + required this.east, + required this.north, + required this.zoom, + }); + + static const _collocationMaxDeltaThreshold = 360 / (2 << 19); + + factory ZoomedBounds.fromPoints({ + required Set points, + double collocationZoom = 20, + }) { + var west = .0, south = .0, east = .0, north = .0; + var zoom = collocationZoom; + + if (points.isNotEmpty) { + final first = points.first; + west = first.longitude; + south = first.latitude; + east = first.longitude; + north = first.latitude; + + for (var point in points) { + final lng = point.longitude; + final lat = point.latitude; + if (lng < west) west = lng; + if (lat < south) south = lat; + if (lng > east) east = lng; + if (lat > north) north = lat; + } + + final boundsDelta = max(north - south, east - west); + if (boundsDelta > _collocationMaxDeltaThreshold) { + zoom = max(1, log(360) / ln2 - log(boundsDelta) / ln2); + } + } + return ZoomedBounds( + west: west, + south: south, + east: east, + north: north, + zoom: zoom, + ); + } +} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 8ac9e89ea..fc5c9480a 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -7,8 +7,8 @@ import 'package:aves/widgets/debug/android_dirs.dart'; import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; -import 'package:aves/widgets/debug/firebase.dart'; import 'package:aves/widgets/debug/overlay.dart'; +import 'package:aves/widgets/debug/report.dart'; import 'package:aves/widgets/debug/settings.dart'; import 'package:aves/widgets/debug/storage.dart'; import 'package:aves/widgets/viewer/info/common.dart'; diff --git a/lib/widgets/debug/firebase.dart b/lib/widgets/debug/firebase.dart deleted file mode 100644 index be073a05e..000000000 --- a/lib/widgets/debug/firebase.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; -import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter/material.dart'; - -class DebugFirebaseSection extends StatelessWidget { - const DebugFirebaseSection({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return AvesExpansionTile( - title: 'Firebase', - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: ElevatedButton( - onPressed: FirebaseCrashlytics.instance.crash, - child: const Text('Crash'), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: InfoRowGroup( - info: { - 'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}', - 'Crashlytics collection enabled': '${FirebaseCrashlytics.instance.isCrashlyticsCollectionEnabled}', - }, - ), - ) - ], - ); - } -} diff --git a/lib/widgets/debug/report.dart b/lib/widgets/debug/report.dart new file mode 100644 index 000000000..fd5d842be --- /dev/null +++ b/lib/widgets/debug/report.dart @@ -0,0 +1,63 @@ +import 'package:aves/services/android_debug_service.dart'; +import 'package:aves/services/services.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; + +class DebugFirebaseSection extends StatelessWidget { + const DebugFirebaseSection({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AvesExpansionTile( + title: 'Reporting', + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: AndroidDebugService.crash, + child: Text('Crash'), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: AndroidDebugService.exception, + child: Text('Throw exception'), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: AndroidDebugService.safeException, + child: Text('Throw exception (safe)'), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: AndroidDebugService.exceptionInCoroutine, + child: Text('Throw exception in coroutine'), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: ElevatedButton( + onPressed: AndroidDebugService.safeExceptionInCoroutine, + child: Text('Throw exception in coroutine (safe)'), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: InfoRowGroup( + info: { + 'Firebase data collection enabled': '${Firebase.app().isAutomaticDataCollectionEnabled}', + 'Crashlytics collection enabled': '${reportService.isCollectionEnabled}', + }, + ), + ) + ], + ); + } +} diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart index 270cd847f..ad4a7ab69 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -153,7 +153,7 @@ class _CreateAlbumDialogState extends State { final path = _buildAlbumPath(newName); final exists = newName.isNotEmpty && await Directory(path).exists(); _existsNotifier.value = exists; - _isValidNotifier.value = newName.isNotEmpty; + _isValidNotifier.value = newName.isNotEmpty && !exists; } void _submit(BuildContext context) => Navigator.pop(context, _buildAlbumPath(_nameController.text)); diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 99333753e..1ff248eb8 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -14,7 +14,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/cover_selection_dialog.dart'; -import 'package:aves/widgets/stats/stats.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/scheduler.dart'; @@ -51,6 +52,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.select: case ChipSetAction.selectAll: case ChipSetAction.selectNone: + case ChipSetAction.map: case ChipSetAction.stats: case ChipSetAction.createAlbum: return true; @@ -73,6 +75,9 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.sort: _showSortDialog(context); break; + case ChipSetAction.map: + _goToMap(context); + break; case ChipSetAction.stats: _goToStats(context); break; @@ -124,6 +129,19 @@ abstract class ChipSetActionDelegate with FeedbackMi } } + void _goToMap(BuildContext context) { + final source = context.read(); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: MapPage.routeName), + builder: (context) => MapPage( + source: source, + ), + ), + ); + } + void _goToStats(BuildContext context) { final source = context.read(); Navigator.push( diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 00d4c23f9..53d188ebd 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -204,6 +204,7 @@ class _FilterGridAppBarState extends State get props => [title]; + const ChipSectionKey({ this.title = '', }); Widget? get leading => null; - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is ChipSectionKey && other.title == title; - } - - @override - int get hashCode => title.hashCode; - - @override - String toString() => '$runtimeType#${shortHash(this)}{title=$title}'; } class AlbumImportanceSectionKey extends ChipSectionKey { diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 39bc3722e..86e15c017 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -6,6 +6,7 @@ import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/global_search.dart'; import 'package:aves/services/services.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -16,7 +17,6 @@ import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_page.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -39,7 +39,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { AvesEntry? _viewerEntry; - String? _shortcutRouteName; + String? _shortcutRouteName, _shortcutSearchQuery; List? _shortcutFilters; static const allowedShortcutRoutes = [CollectionPage.routeName, AlbumListPage.routeName, SearchPage.routeName]; @@ -90,6 +90,10 @@ class _HomePageState extends State { String? pickMimeTypes = intentData['mimeType']; debugPrint('pick mimeType=$pickMimeTypes'); break; + case 'search': + _shortcutRouteName = SearchPage.routeName; + _shortcutSearchQuery = intentData['query']; + break; default: // do not use 'route' as extra key, as the Flutter framework acts on it final extraRoute = intentData['page']; @@ -101,12 +105,13 @@ class _HomePageState extends State { } } context.read>().value = appMode; - unawaited(FirebaseCrashlytics.instance.setCustomKey('app_mode', appMode.toString())); + unawaited(reportService.setCustomKey('app_mode', appMode.toString())); if (appMode != AppMode.view) { final source = context.read(); await source.init(); unawaited(source.refresh()); + unawaited(GlobalSearch.registerCallback()); } // `pushReplacement` is not enough in some edge cases @@ -154,7 +159,10 @@ class _HomePageState extends State { ); case SearchPage.routeName: return SearchPageRoute( - delegate: CollectionSearchDelegate(source: source), + delegate: CollectionSearchDelegate( + source: source, + initialQuery: _shortcutSearchQuery, + ), ); case CollectionPage.routeName: default: diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart new file mode 100644 index 000000000..3e424ac83 --- /dev/null +++ b/lib/widgets/map/map_page.dart @@ -0,0 +1,67 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/settings/map_style.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class MapPage extends StatefulWidget { + static const routeName = '/collection/map'; + + final CollectionSource source; + final CollectionLens? parentCollection; + late final List entries; + + MapPage({ + Key? key, + required this.source, + this.parentCollection, + }) : super(key: key) { + entries = (parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries).where((entry) => entry.hasGps).toList(); + } + + @override + _MapPageState createState() => _MapPageState(); +} + +class _MapPageState extends State { + late final ValueNotifier _isAnimatingNotifier; + + @override + void initState() { + super.initState(); + if (settings.infoMapStyle.isGoogleMaps) { + _isAnimatingNotifier = ValueNotifier(true); + Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { + if (!mounted) return; + _isAnimatingNotifier.value = false; + }); + } else { + _isAnimatingNotifier = ValueNotifier(false); + } + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.mapPageTitle), + ), + body: SafeArea( + child: GeoMap( + entries: widget.entries, + interactive: true, + isAnimatingNotifier: _isAnimatingNotifier, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index e1a6928a2..65d8f6dbe 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -42,7 +42,13 @@ class CollectionSearchDelegate { MimeFilter(MimeTypes.svg), ]; - CollectionSearchDelegate({required this.source, this.parentCollection}); + CollectionSearchDelegate({ + required this.source, + this.parentCollection, + String? initialQuery, + }) { + query = initialQuery ?? ''; + } Widget buildLeading(BuildContext context) { return Navigator.canPop(context) diff --git a/lib/widgets/settings/viewer/viewer_actions_editor.dart b/lib/widgets/settings/viewer/viewer_actions_editor.dart index 33eebc9ce..f0b048731 100644 --- a/lib/widgets/settings/viewer/viewer_actions_editor.dart +++ b/lib/widgets/settings/viewer/viewer_actions_editor.dart @@ -36,6 +36,7 @@ class ViewerActionEditorPage extends StatelessWidget { EntryAction.delete, EntryAction.rename, EntryAction.export, + EntryAction.copyToClipboard, EntryAction.print, EntryAction.rotateScreen, EntryAction.flip, diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats_page.dart similarity index 100% rename from lib/widgets/stats/stats.dart rename to lib/widgets/stats/stats_page.dart diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index d351d935b..73f376fb6 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -122,7 +122,6 @@ class ViewerDebugPage extends StatelessWidget { 'hasAddress': '${entry.hasAddress}', 'hasFineAddress': '${entry.hasFineAddress}', 'latLng': '${entry.latLng}', - 'geoUri': entry.geoUri ?? '', }, ), ], diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index c981424a3..e7b0793d6 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -37,6 +37,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.toggleFavourite: entry.toggleFavourite(); break; + case EntryAction.copyToClipboard: + AndroidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { + showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback); + }); + break; case EntryAction.delete: _showDeleteDialog(context, entry); break; @@ -46,12 +51,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.info: ShowInfoNotification().dispatch(context); break; - case EntryAction.rename: - _showRenameDialog(context, entry); - break; case EntryAction.print: EntryPrinter(entry).print(context); break; + case EntryAction.rename: + _showRenameDialog(context, entry); + break; case EntryAction.rotateCCW: _rotate(context, entry, clockwise: false); break; @@ -72,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix }); break; case EntryAction.openMap: - AndroidAppService.openMap(entry.geoUri!).then((success) { + AndroidAppService.openMap(entry.latLng!).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/viewer/hero.dart b/lib/widgets/viewer/hero.dart index b502a0a93..6c828d519 100644 --- a/lib/widgets/viewer/hero.dart +++ b/lib/widgets/viewer/hero.dart @@ -1,21 +1,17 @@ import 'package:aves/model/entry.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; -class HeroInfo { +@immutable +class HeroInfo extends Equatable { // hero tag should include a collection identifier, so that it animates // between different views of the entry in the same collection (e.g. thumbnails <-> viewer) // but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer) final int? collectionId; final AvesEntry? entry; + @override + List get props => [collectionId, entry?.uri]; + const HeroInfo(this.collectionId, this.entry); - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is HeroInfo && other.collectionId == collectionId && other.entry == entry; - } - - @override - int get hashCode => hashValues(collectionId, entry); } diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index 1a64ec9ff..bb3ecba87 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -1,23 +1,15 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/settings/coordinate_format.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/model/source/collection_lens.dart'; import 'package:aves/services/services.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/google_map.dart'; -import 'package:aves/widgets/viewer/info/maps/leaflet_map.dart'; -import 'package:aves/widgets/viewer/info/maps/marker.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class LocationSection extends StatefulWidget { final CollectionLens? collection; @@ -39,16 +31,7 @@ class LocationSection extends StatefulWidget { _LocationSectionState createState() => _LocationSectionState(); } -class _LocationSectionState extends State with TickerProviderStateMixin { - // as of google_maps_flutter v2.0.6, Google Maps initialization is blocking - // cf https://github.com/flutter/flutter/issues/28493 - // it is especially severe the first time, but still significant afterwards - // so we prevent loading it while scrolling or animating - bool _googleMapsLoaded = false; - - static const extent = 48.0; - static const pointerSize = Size(8.0, 6.0); - +class _LocationSectionState extends State { CollectionLens? get collection => widget.collection; AvesEntry get entry => widget.entry; @@ -85,8 +68,6 @@ class _LocationSectionState extends State with TickerProviderSt @override Widget build(BuildContext context) { if (!entry.hasGps) return const SizedBox(); - final latLng = entry.latLng!; - final geoUri = entry.geoUri!; final filters = []; if (entry.hasAddress) { @@ -97,74 +78,16 @@ class _LocationSectionState extends State with TickerProviderSt if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } - Widget buildMarker(BuildContext context) => ImageMarker( - entry: entry, - extent: extent, - pointerSize: pointerSize, - ); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showTitle) const SectionRow(icon: AIcons.location), - FutureBuilder( - future: availability.isConnected, - builder: (context, snapshot) { - if (snapshot.data != true) return const SizedBox(); - return Selector( - selector: (context, s) => s.infoMapStyle, - builder: (context, mapStyle, child) { - final isGoogleMaps = mapStyle.isGoogleMaps; - return AnimatedSize( - alignment: Alignment.topCenter, - curve: Curves.easeInOutCubic, - duration: Durations.mapStyleSwitchAnimation, - vsync: this, - child: ValueListenableBuilder( - valueListenable: widget.isScrollingNotifier, - builder: (context, scrolling, child) { - if (!scrolling && isGoogleMaps) { - _googleMapsLoaded = true; - } - return Visibility( - visible: !isGoogleMaps || _googleMapsLoaded, - replacement: Stack( - children: [ - const MapDecorator(), - MapButtonPanel( - geoUri: geoUri, - zoomBy: (_) {}, - ), - ], - ), - child: child!, - ); - }, - child: isGoogleMaps - ? EntryGoogleMap( - // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package - latLng: Tuple2(latLng.latitude, latLng.longitude), - geoUri: geoUri, - initialZoom: settings.infoMapZoom, - markerId: entry.uri, - markerBuilder: buildMarker, - ) - : EntryLeafletMap( - latLng: latLng, - geoUri: geoUri, - initialZoom: settings.infoMapZoom, - style: settings.infoMapStyle, - markerSize: Size( - extent + ImageMarker.outerBorderWidth * 2, - extent + ImageMarker.outerBorderWidth * 2 + pointerSize.height, - ), - markerBuilder: buildMarker, - ), - ), - ); - }, - ); - }, + GeoMap( + entries: [entry], + interactive: false, + mapHeight: 200, + isAnimatingNotifier: widget.isScrollingNotifier, + onUserZoomChange: (zoom) => settings.infoMapZoom = zoom, ), _AddressInfoGroup(entry: entry), if (filters.isNotEmpty) diff --git a/lib/widgets/viewer/info/maps/google_map.dart b/lib/widgets/viewer/info/maps/google_map.dart deleted file mode 100644 index 74744ba68..000000000 --- a/lib/widgets/viewer/info/maps/google_map.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/marker.dart'; -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:tuple/tuple.dart'; - -class EntryGoogleMap extends StatefulWidget { - final LatLng latLng; - final String geoUri; - final double initialZoom; - final String markerId; - final WidgetBuilder markerBuilder; - - EntryGoogleMap({ - Key? key, - required Tuple2 latLng, - required this.geoUri, - required this.initialZoom, - required this.markerId, - required this.markerBuilder, - }) : latLng = LatLng(latLng.item1, latLng.item2), - super(key: key); - - @override - State createState() => _EntryGoogleMapState(); -} - -class _EntryGoogleMapState extends State { - GoogleMapController? _controller; - late Completer _markerLoaderCompleter; - - @override - void initState() { - super.initState(); - _markerLoaderCompleter = Completer(); - } - - @override - void didUpdateWidget(covariant EntryGoogleMap oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.latLng != oldWidget.latLng && _controller != null) { - _controller!.moveCamera(CameraUpdate.newLatLng(widget.latLng)); - } - if (widget.markerId != oldWidget.markerId) { - _markerLoaderCompleter = Completer(); - } - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - MarkerGeneratorWidget( - key: Key(widget.markerId), - markers: [widget.markerBuilder(context)], - onComplete: (bitmaps) => _markerLoaderCompleter.complete(bitmaps.first), - ), - MapDecorator( - child: _buildMap(), - ), - MapButtonPanel( - geoUri: widget.geoUri, - zoomBy: _zoomBy, - ), - ], - ); - } - - Widget _buildMap() { - return FutureBuilder( - future: _markerLoaderCompleter.future, - builder: (context, snapshot) { - final markers = {}; - if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) { - final markerBytes = snapshot.data!; - markers.add(Marker( - markerId: MarkerId(widget.markerId), - icon: BitmapDescriptor.fromBytes(markerBytes), - position: widget.latLng, - )); - } - return GoogleMap( - // GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493 - initialCameraPosition: CameraPosition( - target: widget.latLng, - zoom: widget.initialZoom, - ), - onMapCreated: (controller) => setState(() => _controller = controller), - compassEnabled: false, - mapToolbarEnabled: false, - mapType: _toMapStyle(settings.infoMapStyle), - rotateGesturesEnabled: false, - scrollGesturesEnabled: false, - zoomControlsEnabled: false, - zoomGesturesEnabled: false, - liteModeEnabled: false, - // no camera animation in lite mode - tiltGesturesEnabled: false, - myLocationEnabled: false, - myLocationButtonEnabled: false, - markers: markers, - ); - }); - } - - void _zoomBy(double amount) { - settings.infoMapZoom += amount; - _controller?.animateCamera(CameraUpdate.zoomBy(amount)); - } - - MapType _toMapStyle(EntryMapStyle style) { - switch (style) { - case EntryMapStyle.googleNormal: - return MapType.normal; - case EntryMapStyle.googleHybrid: - return MapType.hybrid; - case EntryMapStyle.googleTerrain: - return MapType.terrain; - default: - return MapType.none; - } - } -} diff --git a/lib/widgets/viewer/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart deleted file mode 100644 index aed8f9e05..000000000 --- a/lib/widgets/viewer/info/maps/leaflet_map.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:aves/model/settings/enums.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/maps/common.dart'; -import 'package:aves/widgets/viewer/info/maps/scale_layer.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class EntryLeafletMap extends StatefulWidget { - final LatLng latLng; - final String geoUri; - final double initialZoom; - final EntryMapStyle style; - final Size markerSize; - final WidgetBuilder markerBuilder; - - const EntryLeafletMap({ - Key? key, - required this.latLng, - required this.geoUri, - required this.initialZoom, - required this.style, - required this.markerBuilder, - required this.markerSize, - }) : super(key: key); - - @override - State createState() => _EntryLeafletMapState(); -} - -class _EntryLeafletMapState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); - - @override - void didUpdateWidget(covariant EntryLeafletMap oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.latLng != oldWidget.latLng) { - _mapController.move(widget.latLng, settings.infoMapZoom); - } - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - MapDecorator( - child: _buildMap(), - ), - MapButtonPanel( - geoUri: widget.geoUri, - zoomBy: _zoomBy, - ), - ], - ), - _buildAttribution(), - ], - ); - } - - Widget _buildMap() { - return FlutterMap( - options: MapOptions( - center: widget.latLng, - zoom: widget.initialZoom, - interactiveFlags: InteractiveFlag.none, - ), - mapController: _mapController, - children: [ - _buildMapLayer(), - ScaleLayerWidget( - options: ScaleLayerOptions(), - ), - MarkerLayerWidget( - options: MarkerLayerOptions( - markers: [ - Marker( - width: widget.markerSize.width, - height: widget.markerSize.height, - point: widget.latLng, - builder: widget.markerBuilder, - anchorPos: AnchorPos.align(AnchorAlign.top), - ), - ], - ), - ), - ], - ); - } - - Widget _buildMapLayer() { - switch (widget.style) { - case EntryMapStyle.osmHot: - return const OSMHotLayer(); - case EntryMapStyle.stamenToner: - return const StamenTonerLayer(); - case EntryMapStyle.stamenWatercolor: - return const StamenWatercolorLayer(); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildAttribution() { - switch (widget.style) { - case EntryMapStyle.osmHot: - return _buildAttributionMarkdown(context.l10n.mapAttributionOsmHot); - case EntryMapStyle.stamenToner: - case EntryMapStyle.stamenWatercolor: - return _buildAttributionMarkdown(context.l10n.mapAttributionStamen); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildAttributionMarkdown(String data) { - return Padding( - padding: const EdgeInsets.only(top: 4), - child: MarkdownBody( - data: data, - selectable: true, - styleSheet: MarkdownStyleSheet( - a: TextStyle(color: Theme.of(context).accentColor), - p: const TextStyle(color: Colors.white70, fontSize: InfoRowGroup.fontSize), - ), - onTapLink: (text, href, title) async { - if (href != null && await canLaunch(href)) { - await launch(href); - } - }, - ), - ); - } - - void _zoomBy(double amount) { - final endZoom = (settings.infoMapZoom + amount).clamp(1.0, 16.0); - settings.infoMapZoom = endZoom; - - final zoomTween = Tween(begin: _mapController.zoom, end: endZoom); - final controller = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); - final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); - controller.addListener(() => _mapController.move(widget.latLng, zoomTween.evaluate(animation))); - animation.addStatusListener((status) { - if (status == AnimationStatus.completed) { - controller.dispose(); - } else if (status == AnimationStatus.dismissed) { - controller.dispose(); - } - }); - controller.forward(); - } -} - -class OSMHotLayer extends StatelessWidget { - const OSMHotLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', - subdomains: ['a', 'b', 'c'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} - -class StamenTonerLayer extends StatelessWidget { - const StamenTonerLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', - subdomains: ['a', 'b', 'c', 'd'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} - -class StamenWatercolorLayer extends StatelessWidget { - const StamenWatercolorLayer({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TileLayerWidget( - options: TileLayerOptions( - urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', - subdomains: ['a', 'b', 'c', 'd'], - retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, - ), - ); - } -} diff --git a/lib/widgets/viewer/info/maps/scalebar_utils.dart b/lib/widgets/viewer/info/maps/scalebar_utils.dart deleted file mode 100644 index 8a3df22c7..000000000 --- a/lib/widgets/viewer/info/maps/scalebar_utils.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:math'; - -import 'package:aves/utils/math_utils.dart'; -import 'package:latlong2/latlong.dart'; - -LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { - var mSemiMajorAxis = 6378137.0; //WGS84 major axis - var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; - var mFlattening = 1.0 / 298.257223563; - // double mInverseFlattening = 298.257223563; - - var a = mSemiMajorAxis; - var b = mSemiMinorAxis; - var aSquared = a * a; - var bSquared = b * b; - var f = mFlattening; - var phi1 = toRadians(start.latitude); - var alpha1 = toRadians(startBearing); - var cosAlpha1 = cos(alpha1); - var sinAlpha1 = sin(alpha1); - var s = distance; - var tanU1 = (1.0 - f) * tan(phi1); - var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); - var sinU1 = tanU1 * cosU1; - - // eq. 1 - var sigma1 = atan2(tanU1, cosAlpha1); - - // eq. 2 - var sinAlpha = cosU1 * sinAlpha1; - - var sin2Alpha = sinAlpha * sinAlpha; - var cos2Alpha = 1 - sin2Alpha; - var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared; - - // eq. 3 - var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared))); - - // eq. 4 - var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared))); - - // iterate until there is a negligible change in sigma - double deltaSigma; - var sOverbA = s / (b * A); - var sigma = sOverbA; - double sinSigma; - var prevSigma = sOverbA; - double sigmaM2; - double cosSigmaM2; - double cos2SigmaM2; - - for (;;) { - // eq. 5 - sigmaM2 = 2.0 * sigma1 + sigma; - cosSigmaM2 = cos(sigmaM2); - cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; - sinSigma = sin(sigma); - var cosSignma = cos(sigma); - - // eq. 6 - deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2))); - - // eq. 7 - sigma = sOverbA + deltaSigma; - - // break after converging to tolerance - if ((sigma - prevSigma).abs() < 0.0000000000001) break; - - prevSigma = sigma; - } - - sigmaM2 = 2.0 * sigma1 + sigma; - cosSigmaM2 = cos(sigmaM2); - cos2SigmaM2 = cosSigmaM2 * cosSigmaM2; - - var cosSigma = cos(sigma); - sinSigma = sin(sigma); - - // eq. 8 - var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0))); - - // eq. 9 - // This fixes the pole crossing defect spotted by Matt Feemster. When a - // path passes a pole and essentially crosses a line of latitude twice - - // once in each direction - the longitude calculation got messed up. - // Using - // atan2 instead of atan fixes the defect. The change is in the next 3 - // lines. - // double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 * - // sinSigma * cosAlpha1); - // double lambda = Math.atan(tanLambda); - var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1)); - - // eq. 10 - var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha)); - - // eq. 11 - var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2))); - - // eq. 12 - // double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 * - // cosSigma * cosAlpha1); - - // build result - var latitude = toDegrees(phi2); - var longitude = start.longitude + toDegrees(L); - - // if ((endBearing != null) && (endBearing.length > 0)) { - // endBearing[0] = toDegrees(alpha2); - // } - - latitude = latitude < -90 ? -90 : latitude; - latitude = latitude > 90 ? 90 : latitude; - longitude = longitude < -180 ? -180 : longitude; - longitude = longitude > 180 ? 180 : longitude; - return LatLng(latitude, longitude); -} diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index c4c056260..81fbde239 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -12,13 +12,18 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_ns/photoshop.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/tiff.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_ns/xmp.dart'; import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class XmpNamespace { +@immutable +class XmpNamespace extends Equatable { final String namespace; final Map rawProps; + @override + List get props => [namespace]; + const XmpNamespace(this.namespace, this.rawProps); factory XmpNamespace.create(String namespace, Map rawProps) { @@ -119,20 +124,6 @@ class XmpNamespace { String formatValue(XmpProp prop) => prop.value; Map linkifyValues(List props) => {}; - - // identity - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is XmpNamespace && other.namespace == namespace; - } - - @override - int get hashCode => namespace.hashCode; - - @override - String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } class XmpProp { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart index 4ec4f1cee..d3493a7b1 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/exif.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; class XmpExifNamespace extends XmpNamespace { static const ns = 'exif'; - XmpExifNamespace(Map rawProps) : super(ns, rawProps); + const XmpExifNamespace(Map rawProps) : super(ns, rawProps); @override String get displayTitle => 'Exif'; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index fa4f8784b..906b6a893 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -6,7 +6,7 @@ import 'package:collection/collection.dart'; import 'package:tuple/tuple.dart'; abstract class XmpGoogleNamespace extends XmpNamespace { - XmpGoogleNamespace(String ns, Map rawProps) : super(ns, rawProps); + const XmpGoogleNamespace(String ns, Map rawProps) : super(ns, rawProps); List> get dataProps; @@ -35,7 +35,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace { class XmpGAudioNamespace extends XmpGoogleNamespace { static const ns = 'GAudio'; - XmpGAudioNamespace(Map rawProps) : super(ns, rawProps); + const XmpGAudioNamespace(Map rawProps) : super(ns, rawProps); @override List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; @@ -47,7 +47,7 @@ class XmpGAudioNamespace extends XmpGoogleNamespace { class XmpGDepthNamespace extends XmpGoogleNamespace { static const ns = 'GDepth'; - XmpGDepthNamespace(Map rawProps) : super(ns, rawProps); + const XmpGDepthNamespace(Map rawProps) : super(ns, rawProps); @override List> get dataProps => const [ @@ -62,7 +62,7 @@ class XmpGDepthNamespace extends XmpGoogleNamespace { class XmpGImageNamespace extends XmpGoogleNamespace { static const ns = 'GImage'; - XmpGImageNamespace(Map rawProps) : super(ns, rawProps); + const XmpGImageNamespace(Map rawProps) : super(ns, rawProps); @override List> get dataProps => const [Tuple2('$ns:Data', '$ns:Mime')]; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart index 73897fe13..c44068e93 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/photoshop.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/viewer/info/metadata/xmp_namespaces.dart'; class XmpPhotoshopNamespace extends XmpNamespace { static const ns = 'photoshop'; - XmpPhotoshopNamespace(Map rawProps) : super(ns, rawProps); + const XmpPhotoshopNamespace(Map rawProps) : super(ns, rawProps); @override String get displayTitle => 'Photoshop'; diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart index 5c901413f..91c1ba8a4 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/tiff.dart @@ -8,7 +8,7 @@ class XmpTiffNamespace extends XmpNamespace { @override String get displayTitle => 'TIFF'; - XmpTiffNamespace(Map rawProps) : super(ns, rawProps); + const XmpTiffNamespace(Map rawProps) : super(ns, rawProps); @override String formatValue(XmpProp prop) { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 51dd671e4..3b89e15a4 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -114,7 +114,7 @@ class XmpNoteNamespace extends XmpNamespace { // `xmpNote:HasExtendedXMP` is structural and should not be displayed to users static const hasExtendedXmp = '$ns:HasExtendedXMP'; - XmpNoteNamespace(Map rawProps) : super(ns, rawProps); + const XmpNoteNamespace(Map rawProps) : super(ns, rawProps); @override bool extractData(XmpProp prop) { diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 725998922..ba720cc13 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -86,11 +86,12 @@ class ViewerTopOverlay extends StatelessWidget { return targetEntry.isMotionPhoto; case EntryAction.rotateScreen: return settings.isRotationLocked; - case EntryAction.share: + case EntryAction.copyToClipboard: + case EntryAction.edit: case EntryAction.info: case EntryAction.open: - case EntryAction.edit: case EntryAction.setAs: + case EntryAction.share: return true; case EntryAction.debug: return kDebugMode; @@ -197,6 +198,7 @@ class _TopOverlayRow extends StatelessWidget { onPressed: onPressed, ); break; + case EntryAction.copyToClipboard: case EntryAction.delete: case EntryAction.export: case EntryAction.flip: diff --git a/lib/widgets/viewer/visual/subtitle/line.dart b/lib/widgets/viewer/visual/subtitle/line.dart index c7fb5c60a..dd44cb49c 100644 --- a/lib/widgets/viewer/visual/subtitle/line.dart +++ b/lib/widgets/viewer/visual/subtitle/line.dart @@ -1,13 +1,17 @@ import 'package:aves/widgets/viewer/visual/subtitle/span.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class StyledSubtitleLine with Diagnosticable { +class StyledSubtitleLine extends Equatable with Diagnosticable { final List spans; final List? clip; final Offset? position; + @override + List get props => [spans, clip, position]; + const StyledSubtitleLine({ required this.spans, this.clip, @@ -33,17 +37,4 @@ class StyledSubtitleLine with Diagnosticable { properties.add(DiagnosticsProperty>('clip', clip)); properties.add(DiagnosticsProperty('position', position)); } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is StyledSubtitleLine && other.spans == spans && other.clip == clip && other.position == position; - } - - @override - int get hashCode => hashValues( - spans, - clip, - position, - ); } diff --git a/lib/widgets/viewer/visual/subtitle/span.dart b/lib/widgets/viewer/visual/subtitle/span.dart index b83389591..8cee296a0 100644 --- a/lib/widgets/viewer/visual/subtitle/span.dart +++ b/lib/widgets/viewer/visual/subtitle/span.dart @@ -1,12 +1,16 @@ import 'package:aves/widgets/viewer/visual/subtitle/style.dart'; +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class StyledSubtitleSpan with Diagnosticable { +class StyledSubtitleSpan extends Equatable with Diagnosticable { final TextSpan textSpan; final SubtitleStyle extraStyle; + @override + List get props => [textSpan, extraStyle]; + const StyledSubtitleSpan({ required this.textSpan, required this.extraStyle, @@ -28,16 +32,4 @@ class StyledSubtitleSpan with Diagnosticable { properties.add(DiagnosticsProperty('textSpan', textSpan)); properties.add(DiagnosticsProperty('extraStyle', extraStyle)); } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is StyledSubtitleSpan && other.textSpan == textSpan && other.extraStyle == extraStyle; - } - - @override - int get hashCode => hashValues( - textSpan, - extraStyle, - ); } diff --git a/lib/widgets/viewer/visual/subtitle/style.dart b/lib/widgets/viewer/visual/subtitle/style.dart index af9a19948..0519cfe17 100644 --- a/lib/widgets/viewer/visual/subtitle/style.dart +++ b/lib/widgets/viewer/visual/subtitle/style.dart @@ -1,8 +1,9 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @immutable -class SubtitleStyle with Diagnosticable { +class SubtitleStyle extends Equatable with Diagnosticable { final TextAlign? hAlign; final TextAlignVertical? vAlign; final Color? borderColor; @@ -15,6 +16,23 @@ class SubtitleStyle with Diagnosticable { bool get shearing => (shearX ?? 0) != 0 || (shearY ?? 0) != 0; + @override + List get props => [ + hAlign, + vAlign, + borderColor, + borderWidth, + edgeBlur, + rotationX, + rotationY, + rotationZ, + scaleX, + scaleY, + shearX, + shearY, + drawingPaths?.length, + ]; + const SubtitleStyle({ this.hAlign, this.vAlign, @@ -80,27 +98,4 @@ class SubtitleStyle with Diagnosticable { properties.add(DoubleProperty('shearY', shearY)); properties.add(DiagnosticsProperty>('drawingPaths', drawingPaths)); } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) return false; - return other is SubtitleStyle && other.hAlign == hAlign && other.vAlign == vAlign && other.borderColor == borderColor && other.borderWidth == borderWidth && other.edgeBlur == edgeBlur && other.rotationX == rotationX && other.rotationY == rotationY && other.rotationZ == rotationZ && other.scaleX == scaleX && other.scaleY == scaleY && other.shearX == shearX && other.shearY == shearY && other.drawingPaths == drawingPaths; - } - - @override - int get hashCode => hashValues( - hAlign, - vAlign, - borderColor, - borderWidth, - edgeBlur, - rotationX, - rotationY, - rotationZ, - scaleX, - scaleY, - shearX, - shearY, - drawingPaths?.length, - ); } diff --git a/pubspec.lock b/pubspec.lock index 4c5e02561..b060c499d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + custom_rounded_rectangle_border: + dependency: "direct main" + description: + name: custom_rounded_rectangle_border + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0-nullsafety.0" dbus: dependency: transitive description: @@ -183,6 +190,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.1" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" event_bus: dependency: "direct main" description: @@ -271,6 +285,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + fluster: + dependency: "direct main" + description: + name: fluster + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index dceee2b57..38da7420e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: aves description: A visual media gallery and metadata explorer app. repository: https://github.com/deckerst/aves -version: 1.4.6+50 +version: 1.4.7+51 publish_to: none environment: @@ -16,7 +16,10 @@ dependencies: collection: connectivity_plus: country_code: +# TODO TLAD as of 2021/08/04, null safe version is pre-release + custom_rounded_rectangle_border: '>=0.2.0-nullsafety.0' decorated_icon: + equatable: event_bus: expansion_tile_card: git: @@ -28,6 +31,7 @@ dependencies: firebase_core: firebase_crashlytics: flex_color_picker: + fluster: flutter_highlight: flutter_map: flutter_markdown: @@ -38,7 +42,7 @@ dependencies: google_maps_flutter: intl: latlong2: -# TODO TLAD as of 2021/07/08, MDI package null safe version is pre-release +# TODO TLAD as of 2021/08/04, null safe version is pre-release material_design_icons_flutter: '>=5.0.5955-rc.1' overlay_support: package_info_plus: diff --git a/scripts/fix_android_log_levels.bat b/scripts/fix_android_log_levels.bat index 537e02b8f..520b0df2d 100644 --- a/scripts/fix_android_log_levels.bat +++ b/scripts/fix_android_log_levels.bat @@ -12,6 +12,8 @@ adb.exe shell setprop log.tag.AHierarchicalStateMachine ERROR adb.exe shell setprop log.tag.AudioCapabilities ERROR adb.exe shell setprop log.tag.AudioTrack INFO adb.exe shell setprop log.tag.CompatibilityChangeReporter INFO +adb.exe shell setprop log.tag.Counters WARN +adb.exe shell setprop log.tag.CustomizedTextParser INFO adb.exe shell setprop log.tag.InputMethodManager WARN adb.exe shell setprop log.tag.InsetsSourceConsumer INFO adb.exe shell setprop log.tag.InputTransport INFO diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index e2b6bfb8a..9b7079466 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -259,6 +259,8 @@ void main() { FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'), FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Pictures/Cicero', '1'), FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Marcus Aurelius', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Hannah Arendt', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Arendt', '1'), }; await androidFileUtils.init(); @@ -276,6 +278,8 @@ void main() { expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca'); expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Cicero'), 'Cicero'); expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Marcus Aurelius'), 'Marcus Aurelius'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Hannah Arendt'), 'Hannah Arendt'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Arendt'), 'Arendt'); return const Placeholder(); }, ), diff --git a/test/utils/android_file_utils.dart b/test/utils/android_file_utils.dart new file mode 100644 index 000000000..66ef35d74 --- /dev/null +++ b/test/utils/android_file_utils.dart @@ -0,0 +1,27 @@ +import 'package:aves/services/services.dart'; +import 'package:aves/services/storage_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../fake/storage_service.dart'; + +void main() { + setUp(() async { + // specify Posix style path context for consistent behaviour when running tests on Windows + getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); + + getIt.registerLazySingleton(() => FakeStorageService()); + + await androidFileUtils.init(); + }); + + tearDown(() async { + await getIt.reset(); + }); + + test('camera album identification', () { + expect(androidFileUtils.isCameraPath('${FakeStorageService.primaryPath}DCIM/Camera'), true); + expect(androidFileUtils.isCameraPath('${FakeStorageService.primaryPath}DCIM/YoloCamera'), false); + }); +} diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index f0c685a6b..6a41abb90 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,7 +1,6 @@ Thanks for using Aves! -v1.4.6: -- burst shot grouping -- improved motion photo support -- select multiple albums -- hide paths to exclude folders and their subfolders +v1.4.7: +- map page +- viewer action to copy to clipboard +- integration with OS global search Full changelog available on Github \ No newline at end of file