Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-08-06 15:35:51 +09:00
commit 9477772ace
138 changed files with 2799 additions and 1592 deletions

View file

@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [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 ## [v1.4.6] - 2021-07-22
### Added ### Added
- Albums / Countries / Tags: multiple selection - Albums / Countries / Tags: multiple selection

View file

@ -6,6 +6,8 @@ plugins {
id 'com.google.firebase.crashlytics' id 'com.google.firebase.crashlytics'
} }
def appId = "deckers.thibault.aves"
// Flutter properties // Flutter properties
def localProperties = new Properties() def localProperties = new Properties()
@ -52,13 +54,18 @@ android {
} }
defaultConfig { 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 minSdkVersion 20
targetSdkVersion 30 targetSdkVersion 30
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']] manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
multiDexEnabled true multiDexEnabled true
resValue 'string', 'search_provider', "${appId}.search_provider"
} }
signingConfigs { signingConfigs {
@ -73,9 +80,11 @@ android {
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
resValue 'string', 'search_provider', "${appId}.debug.search_provider"
} }
profile { profile {
applicationIdSuffix ".profile" applicationIdSuffix ".profile"
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
} }
release { release {
// specify architectures, to specifically exclude native libs for x86, // specify architectures, to specifically exclude native libs for x86,

View file

@ -24,7 +24,7 @@
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- to access media with unredacted metadata with scoped storage (Android Q+) --> <!-- to access media with original metadata with scoped storage (Android Q+) -->
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 --> <!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
@ -38,26 +38,21 @@
</queries> </queries>
<application <application
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round"
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported --> tools:targetApi="lollipop">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/NormalTheme" android:theme="@style/NormalTheme"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@ -65,6 +60,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" /> <data android:mimeType="image/*" />
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
@ -108,11 +104,26 @@
<data android:mimeType="vnd.android.cursor.dir/image" /> <data android:mimeType="vnd.android.cursor.dir/image" />
<data android:mimeType="vnd.android.cursor.dir/video" /> <data android:mimeType="vnd.android.cursor.dir/video" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity> </activity>
<!-- file provider to share files having a file:// URI --> <!-- file provider to share files having a file:// URI -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.file_provider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
@ -120,6 +131,12 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<provider
android:name=".SearchSuggestionsProvider"
android:authorities="@string/search_provider"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<meta-data <meta-data
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="${googleApiKey}" /> android:value="${googleApiKey}" />

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves package deckers.thibault.aves
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@ -29,10 +31,23 @@ class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent") 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) super.onCreate(savedInstanceState)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
// dart -> platform -> dart
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this)) MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
@ -40,18 +55,20 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this)) MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this)) MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler()) MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// result streaming: dart -> platform ->->-> dart
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) } StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
// Media Store change monitoring // change monitoring: platform -> dart
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply { mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this) EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this)
} }
@ -60,9 +77,11 @@ class MainActivity : FlutterActivity() {
} }
// intent handling // intent handling
// notification: platform -> dart
intentStreamHandler = IntentStreamHandler().apply { intentStreamHandler = IntentStreamHandler().apply {
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this) EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
} }
// detail fetch: dart -> platform
intentDataMap = extractIntentData(intent) intentDataMap = extractIntentData(intent)
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result -> MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
setupShortcuts() setupShortcuts()
} }
@ -93,7 +117,14 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) { when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> { 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)
}
}
@SuppressLint("WrongConstant")
private fun onDocumentTreeAccessResult(data: Intent?, resultCode: Int, requestCode: Int) {
val treeUri = data?.data val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
onPermissionResult(requestCode, null) onPermissionResult(requestCode, null)
@ -109,17 +140,13 @@ class MainActivity : FlutterActivity() {
// resume pending action // resume pending action
onPermissionResult(requestCode, treeUri) onPermissionResult(requestCode, treeUri)
} }
DELETE_PERMISSION_REQUEST -> {
private fun onDeletePermissionResult(resultCode: Int) {
// delete permission may be requested on Android 10+ only // delete permission may be requested on Android 10+ only
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
} }
} }
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> {
onPermissionResult(requestCode, data?.data)
}
}
}
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
when (intent?.action) { when (intent?.action) {
@ -146,6 +173,20 @@ class MainActivity : FlutterActivity() {
"mimeType" to intent.type, "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() return HashMap()
} }
@ -211,6 +252,10 @@ class MainActivity : FlutterActivity() {
handler.onDenied() handler.onDenied()
} }
} }
var errorStreamHandler: ErrorStreamHandler? = null
fun notifyError(error: String) = errorStreamHandler?.notifyError(error)
} }
} }

View file

@ -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<String>?, selection: String?, selectionArgs: Array<String>?, 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<FieldMap> {
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<FieldMap>)
}
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<String>?): Int =
throw UnsupportedOperationException()
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int =
throw UnsupportedOperationException()
companion object {
private val LOG_TAG = LogUtils.createTag<SearchSuggestionsProvider>()
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<Boolean> { 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()
}
}
}

View file

@ -1,8 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver import android.content.*
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
@ -12,7 +10,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.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.model.FieldMap
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -31,34 +29,13 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) } "getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) } "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) }
"edit" -> { "copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) }
val title = call.argument<String>("title") "edit" -> safe(call, result, ::edit)
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } "open" -> safe(call, result, ::open)
val mimeType = call.argument<String>("mimeType") "openMap" -> safe(call, result, ::openMap)
result.success(edit(title, uri, mimeType)) "setAs" -> safe(call, result, ::setAs)
} "share" -> safe(call, result, ::share)
"open" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(open(title, uri, mimeType))
}
"openMap" -> {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
result.success(openMap(geoUri))
}
"setAs" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(setAs(title, uri, mimeType))
}
"share" -> {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")!!
result.success(shareMultiple(title, urisByMimeType))
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -156,59 +133,110 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun edit(title: String?, uri: Uri?, mimeType: String?): Boolean { private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
uri ?: return false val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val label = call.argument<String>("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<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("edit-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_EDIT) val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType) .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 { private fun open(call: MethodCall, result: MethodChannel.Result) {
uri ?: return false val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("open-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType) .setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent) val started = safeStartActivityChooser(title, intent)
result.success(started)
} }
private fun openMap(geoUri: Uri?): Boolean { private fun openMap(call: MethodCall, result: MethodChannel.Result) {
geoUri ?: return false val geoUri = call.argument<String>("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) 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 { private fun setAs(call: MethodCall, result: MethodChannel.Result) {
uri ?: return false val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
if (uri == null) {
result.error("setAs-args", "failed because of missing arguments", null)
return
}
val intent = Intent(Intent.ACTION_ATTACH_DATA) val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(getShareableUri(uri), mimeType) .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 { private fun share(call: MethodCall, result: MethodChannel.Result) {
val intent = Intent(Intent.ACTION_SEND) val title = call.argument<String>("title")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")
.setType(mimeType) if (urisByMimeType == null) {
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) result.error("setAs-args", "failed because of missing arguments", null)
return safeStartActivityChooser(title, intent) return
} }
private fun shareMultiple(title: String?, urisByMimeType: Map<String, List<String>>?): Boolean {
urisByMimeType ?: return false
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { Uri.parse(it) }) val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { Uri.parse(it) })
val mimeTypes = urisByMimeType.keys.toTypedArray() val mimeTypes = urisByMimeType.keys.toTypedArray()
// simplify share intent for a single item, as some apps can handle one item but not more // simplify share intent for a single item, as some apps can handle one item but not more
if (uriList.size == 1) { val started = if (uriList.size == 1) {
return shareSingle(title, uriList.first(), mimeTypes.first()) val uri = uriList.first()
} val 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 {
var mimeType = "*/*" var mimeType = "*/*"
if (mimeTypes.size == 1) { if (mimeTypes.size == 1) {
// items have the same mime type & subtype // items have the same mime type & subtype
@ -226,7 +254,10 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList) .putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
.setType(mimeType) .setType(mimeType)
return safeStartActivityChooser(title, intent) safeStartActivityChooser(title, intent)
}
result.success(started)
} }
private fun safeStartActivity(intent: Intent): Boolean { private fun safeStartActivity(intent: Intent): Boolean {
@ -255,7 +286,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return when (uri.scheme?.lowercase(Locale.ROOT)) { return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> { ContentResolver.SCHEME_FILE -> {
uri.path?.let { path -> uri.path?.let { path ->
val authority = "${context.applicationContext.packageName}.fileprovider" val authority = "${context.applicationContext.packageName}.file_provider"
FileProvider.getUriForFile(context, authority, File(path)) FileProvider.getUriForFile(context, authority, File(path))
} }
} }

View file

@ -8,6 +8,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.R import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -20,23 +21,31 @@ import java.util.*
class AppShortcutHandler(private val context: Context) : MethodCallHandler { class AppShortcutHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"canPin" -> result.success(canPin()) "canPin" -> safe(call, result, ::canPin)
"pin" -> { "pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
GlobalScope.launch(Dispatchers.IO) { pin(call) }
result.success(null)
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun pin(call: MethodCall) { private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
if (!canPin()) return result.success(isSupported())
}
val label = call.argument<String>("label") ?: return private fun pin(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes") val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters") ?: return val filters = call.argument<List<String>>("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 var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) { if (iconBytes?.isNotEmpty() == true) {
@ -62,6 +71,8 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
.setIntent(intent) .setIntent(intent)
.build() .build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null) ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
result.success(true)
} }
companion object { companion object {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import deckers.thibault.aves.MainActivity
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -8,24 +9,42 @@ import kotlinx.coroutines.launch
import kotlin.reflect.KSuspendFunction2 import kotlin.reflect.KSuspendFunction2
// ensure `result` methods are called on the main looper thread // ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result { class Coresult internal constructor(private val call: MethodCall, private val methodResult: MethodChannel.Result) : MethodChannel.Result {
private val mainScope = CoroutineScope(Dispatchers.Main) private val mainScope = CoroutineScope(Dispatchers.Main)
override fun success(result: Any?) { 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?) { 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() { 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 { companion object {
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) { fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
val res = Coresult(result) val res = Coresult(call, result)
try { try {
function(call, res) function(call, res)
} catch (e: Exception) { } 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<MethodCall, MethodChannel.Result, Unit>) { suspend fun safeSuspend(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
val res = Coresult(result) val res = Coresult(call, result)
try { try {
function(call, res) function(call, res)
} catch (e: Exception) { } catch (e: Exception) {
res.error("safe-exception", e.message, e.stackTraceToString()) res.error("safeSuspend-exception", e.message, e.stackTraceToString())
} }
} }
} }

View file

@ -1,12 +1,13 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
@ -37,8 +38,15 @@ import java.util.*
class DebugHandler(private val context: Context) : MethodCallHandler { class DebugHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getContextDirs" -> result.success(getContextDirs()) "crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
"getEnv" -> result.success(System.getenv()) "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) } "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) } "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) } "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
@ -49,7 +57,8 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun getContextDirs() = hashMapOf( private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf(
"cacheDir" to context.cacheDir, "cacheDir" to context.cacheDir,
"filesDir" to context.filesDir, "filesDir" to context.filesDir,
"obbDir" to context.obbDir, "obbDir" to context.obbDir,
@ -68,6 +77,13 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
}.mapValues { it.value?.path } }.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) { private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { if (uri == null) {
@ -105,7 +121,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
var contentUri: Uri = uri 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 -> uri.tryParseId()?.let { id ->
contentUri = when { contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) 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<DebugHandler>() private val LOG_TAG = LogUtils.createTag<DebugHandler>()
const val CHANNEL = "deckers.thibault/aves/debug" const val CHANNEL = "deckers.thibault/aves/debug"
} }
class TestException internal constructor() : RuntimeException("oops")
} }

View file

@ -1,6 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.os.Build import android.os.Build
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -8,17 +9,18 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
class DeviceHandler : MethodCallHandler { class DeviceHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getPerformanceClass" -> result.success(getPerformanceClass()) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented() 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 // TODO TLAD uncomment when the future is here
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // 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 { companion object {

View file

@ -12,7 +12,7 @@ import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.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.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MultiPage import deckers.thibault.aves.metadata.MultiPage
@ -44,7 +44,7 @@ import java.util.*
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler { class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"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) } "extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) } "extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } "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) { val uri = if (displayName != null) {
// add extension to ease type identification when sharing this content // add extension to ease type identification when sharing this content
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) { val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.location.Geocoder import android.location.Geocoder
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler 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) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { 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() else -> result.notImplemented()
} }
} }

View file

@ -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<Number>("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<GlobalSearchHandler>()
const val CHANNEL = "deckers.thibault/aves/global_search"
}
}

View file

@ -5,7 +5,7 @@ import android.graphics.Rect
import android.net.Uri import android.net.Uri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.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.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher 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) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) } "getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) } "rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) } "rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) } "flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }

View file

@ -1,6 +1,5 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
@ -692,7 +691,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
var contentUri: Uri = uri 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 -> uri.tryParseId()?.let { id ->
contentUri = when { contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)

View file

@ -23,13 +23,13 @@ import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler { class StorageHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getStorageVolumes" -> safe(call, result, ::getStorageVolumes) "getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) }
"getFreeSpace" -> safe(call, result, ::getFreeSpace) "getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories) "getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories) "getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories) "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "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) } "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented() 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) { private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
val volumes = ArrayList<Map<String, Any>>() val volumes = ArrayList<Map<String, Any>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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) { if (sm != null) {
for (volumePath in getVolumePaths(context)) { for (volumePath in getVolumePaths(context)) {
try { try {

View file

@ -1,5 +1,6 @@
package deckers.thibault.aves.channel.calls 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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -8,11 +9,15 @@ import java.util.*
class TimeHandler : MethodCallHandler { class TimeHandler : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id) "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/time" const val CHANNEL = "deckers.thibault/aves/time"
} }

View file

@ -17,7 +17,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
"keepScreenOn" -> safe(call, result, ::keepScreenOn) "keepScreenOn" -> safe(call, result, ::keepScreenOn)
"isRotationLocked" -> safe(call, result, ::isRotationLocked) "isRotationLocked" -> safe(call, result, ::isRotationLocked)
"requestOrientation" -> safe(call, result, ::requestOrientation) "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) "setCutoutMode" -> safe(call, result, ::setCutoutMode)
else -> result.notImplemented() else -> result.notImplemented()
} }
@ -60,6 +60,10 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(true) 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) { private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
val use = call.argument<Boolean>("use") val use = call.argument<Boolean>("use")
if (use == null) { if (use == null) {

View file

@ -24,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -130,7 +131,7 @@ class ThumbnailFetcher internal constructor(
svgFetch -> SvgThumbnail(context, uri) svgFetch -> SvgThumbnail(context, uri)
tiffFetch -> TiffImage(context, uri, pageId) tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> uri else -> StorageUtils.getGlideSafeUri(uri, mimeType)
} }
Glide.with(context) Glide.with(context)
.asBitmap() .asBitmap()

View file

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

View file

@ -120,7 +120,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId) TiffImage(activity, uri, pageId)
} else { } else {
uri StorageUtils.getGlideSafeUri(uri, mimeType)
} }
val target = Glide.with(activity) val target = Glide.with(activity)

View file

@ -39,7 +39,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
when (op) { when (op) {
"requestVolumeAccess" -> requestVolumeAccess() "requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
@ -83,6 +83,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
putExtra(Intent.EXTRA_TITLE, name) putExtra(Intent.EXTRA_TITLE, name)
} }
MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri -> MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri ->
GlobalScope.launch(Dispatchers.IO) {
try { try {
activity.contentResolver.openOutputStream(uri)?.use { output -> activity.contentResolver.openOutputStream(uri)?.use { output ->
output as FileOutputStream output as FileOutputStream
@ -95,6 +96,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
error("createFile-write", "failed to write file at uri=$uri", e.message) error("createFile-write", "failed to write file at uri=$uri", e.message)
} }
endOfStream() endOfStream()
}
}, { }, {
success(null) success(null)
endOfStream() endOfStream()
@ -115,6 +117,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
type = mimeType type = mimeType
} }
MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri -> MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri ->
GlobalScope.launch(Dispatchers.IO) {
activity.contentResolver.openInputStream(uri)?.use { input -> activity.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(BUFFER_SIZE) val buffer = ByteArray(BUFFER_SIZE)
var len: Int var len: Int
@ -123,6 +126,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
} }
endOfStream() endOfStream()
} }
}
}, { }, {
success(ByteArray(0)) success(ByteArray(0))
endOfStream() endOfStream()

View file

@ -142,7 +142,7 @@ abstract class ImageProvider {
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId) TiffImage(context, sourceUri, pageId)
} else { } else {
sourceUri StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
} }
// request a fresh image with the highest quality format // request a fresh image with the highest quality format

View file

@ -2,18 +2,17 @@ package deckers.thibault.aves.model.provider
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import deckers.thibault.aves.utils.StorageUtils
import java.util.* import java.util.*
object ImageProviderFactory { object ImageProviderFactory {
fun getProvider(uri: Uri): ImageProvider? { fun getProvider(uri: Uri): ImageProvider? {
return when (uri.scheme?.lowercase(Locale.ROOT)) { return when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_CONTENT -> { ContentResolver.SCHEME_CONTENT -> {
// a URI's authority is [userinfo@]host[:port] if (StorageUtils.isMediaStoreContentUri(uri)) {
// but we only want the host when comparing to Media Store's "authority" MediaStoreImageProvider()
return when (uri.host?.lowercase(Locale.ROOT)) { } else {
MediaStore.AUTHORITY -> MediaStoreImageProvider() ContentImageProvider()
else -> ContentImageProvider()
} }
} }
ContentResolver.SCHEME_FILE -> FileImageProvider() ContentResolver.SCHEME_FILE -> FileImageProvider()

View file

@ -25,7 +25,7 @@ object PermissionManager {
var intent: Intent? = null var intent: Intent? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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() intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
} }

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.utils
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
@ -15,7 +16,10 @@ import android.text.TextUtils
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat 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.PermissionManager.getGrantedDirForPath
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
@ -183,8 +187,8 @@ object StorageUtils {
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13 // /storage/10F9-3F13/Pictures/ -> 10F9-3F13
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? { private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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
sm.getStorageVolume(File(anyPath))?.let { volume -> sm?.getStorageVolume(File(anyPath))?.let { volume ->
if (volume.isPrimary) { if (volume.isPrimary) {
return "primary" return "primary"
} }
@ -193,7 +197,6 @@ object StorageUtils {
} }
} }
} }
}
// fallback for <N // fallback for <N
getVolumePath(context, anyPath)?.let { volumePath -> getVolumePath(context, anyPath)?.let { volumePath ->
@ -218,7 +221,8 @@ object StorageUtils {
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 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)) { for (volumePath in getVolumePaths(context)) {
try { try {
val volume = sm.getStorageVolume(File(volumePath)) val volume = sm.getStorageVolume(File(volumePath))
@ -395,7 +399,7 @@ object StorageUtils {
return !onPrimaryVolume return !onPrimaryVolume
} }
private fun isMediaStoreContentUri(uri: Uri?): Boolean { fun isMediaStoreContentUri(uri: Uri?): Boolean {
uri ?: return false uri ?: return false
// a URI's authority is [userinfo@]host[:port] // a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority" // 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)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val path = uri.path val path = uri.path
path ?: return uri 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/")) { if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original" // "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
@ -418,6 +422,24 @@ object StorageUtils {
return uri 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? { fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(context, uri) val effectiveUri = getOriginalUri(context, uri)
return try { return try {

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
android:includeInGlobalSearch="true"
android:label="@string/app_name"
android:searchSuggestAuthority="@string/search_provider"
android:searchSuggestIntentAction="android.intent.action.SEARCH"
android:searchSuggestSelection=" ?"
android:searchSuggestThreshold="3" />

View file

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.5.20' ext.kotlin_version = '1.5.21'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { 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 "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.8' classpath 'com.google.gms:google-services:4.3.8'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

View file

@ -1,6 +1,7 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -49,23 +50,18 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
} }
} }
class AppIconImageKey { @immutable
class AppIconImageKey extends Equatable {
final String packageName; final String packageName;
final double size; final double size;
final double scale; final double scale;
@override
List<Object?> get props => [packageName, size, scale];
const AppIconImageKey({ const AppIconImageKey({
required this.packageName, required this.packageName,
required this.size, required this.size,
this.scale = 1.0, 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);
} }

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -62,7 +63,8 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
void pause() => imageFileService.cancelRegion(key); 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 // do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time // but the entry attributes may change over time
final String uri, mimeType; final String uri, mimeType;
@ -72,6 +74,9 @@ class RegionProviderKey {
final Rectangle<int> region; final Rectangle<int> region;
final Size imageSize; final Size imageSize;
@override
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, sampleSize, region, imageSize];
const RegionProviderKey({ const RegionProviderKey({
required this.uri, required this.uri,
required this.mimeType, required this.mimeType,
@ -83,24 +88,6 @@ class RegionProviderKey {
required this.imageSize, 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 @override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
} }

View file

@ -1,6 +1,7 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -63,7 +64,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
void pause() => imageFileService.cancelThumbnail(key); 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 // do not store the entry as it is, because the key should be constant
// but the entry attributes may change over time // but the entry attributes may change over time
final String uri, mimeType; final String uri, mimeType;
@ -73,6 +75,9 @@ class ThumbnailProviderKey {
final int dateModifiedSecs; final int dateModifiedSecs;
final double extent; final double extent;
@override
List<Object?> get props => [uri, pageId, dateModifiedSecs, extent];
const ThumbnailProviderKey({ const ThumbnailProviderKey({
required this.uri, required this.uri,
required this.mimeType, required this.mimeType,
@ -83,20 +88,6 @@ class ThumbnailProviderKey {
this.extent = 0, 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 @override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}';
} }

View file

@ -3,15 +3,20 @@ import 'dart:ui' as ui show Codec;
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart'; import 'package:aves/utils/pedantic.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class UriImage extends ImageProvider<UriImage> { @immutable
class UriImage extends ImageProvider<UriImage> with EquatableMixin {
final String uri, mimeType; final String uri, mimeType;
final int? pageId, rotationDegrees, expectedContentLength; final int? pageId, rotationDegrees, expectedContentLength;
final bool isFlipped; final bool isFlipped;
final double scale; final double scale;
@override
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, scale];
const UriImage({ const UriImage({
required this.uri, required this.uri,
required this.mimeType, required this.mimeType,
@ -71,22 +76,6 @@ class UriImage extends ImageProvider<UriImage> {
} }
} }
@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 @override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}'; String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
} }

View file

@ -62,6 +62,8 @@
"chipActionCreateAlbum": "Create album", "chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {}, "@chipActionCreateAlbum": {},
"entryActionCopyToClipboard": "Copy to clipboard",
"@entryActionCopyToClipboard": {},
"entryActionDelete": "Delete", "entryActionDelete": "Delete",
"@entryActionDelete": {}, "@entryActionDelete": {},
"entryActionExport": "Export", "entryActionExport": "Export",
@ -319,6 +321,8 @@
"@menuActionSort": {}, "@menuActionSort": {},
"menuActionGroup": "Group", "menuActionGroup": "Group",
"@menuActionGroup": {}, "@menuActionGroup": {},
"menuActionMap": "Map",
"@menuActionMap": {},
"menuActionStats": "Stats", "menuActionStats": "Stats",
"@menuActionStats": {}, "@menuActionStats": {},
@ -704,6 +708,9 @@
"settingsCoordinateFormatTitle": "Coordinate Format", "settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {}, "@settingsCoordinateFormatTitle": {},
"mapPageTitle": "Map",
"@mapPageTitle": {},
"statsPageTitle": "Stats", "statsPageTitle": "Stats",
"@statsPageTitle": {}, "@statsPageTitle": {},
"statsImage": "{count, plural, =1{image} other{images}}", "statsImage": "{count, plural, =1{image} other{images}}",

View file

@ -31,6 +31,7 @@
"chipActionSetCover": "대표 이미지 변경", "chipActionSetCover": "대표 이미지 변경",
"chipActionCreateAlbum": "앨범 만들기", "chipActionCreateAlbum": "앨범 만들기",
"entryActionCopyToClipboard": "클립보드에 복사",
"entryActionDelete": "삭제", "entryActionDelete": "삭제",
"entryActionExport": "내보내기", "entryActionExport": "내보내기",
"entryActionInfo": "상세정보", "entryActionInfo": "상세정보",
@ -146,6 +147,7 @@
"menuActionSort": "정렬", "menuActionSort": "정렬",
"menuActionGroup": "묶음", "menuActionGroup": "묶음",
"menuActionMap": "지도",
"menuActionStats": "통계", "menuActionStats": "통계",
"aboutPageTitle": "앱 정보", "aboutPageTitle": "앱 정보",
@ -337,6 +339,8 @@
"settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTile": "좌표 표현",
"settingsCoordinateFormatTitle": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현",
"mapPageTitle": "지도",
"statsPageTitle": "통계", "statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}", "statsImage": "{count, plural, other{사진}}",
"statsVideo": "{count, plural, other{동영상}}", "statsVideo": "{count, plural, other{동영상}}",

View file

@ -1,7 +1,7 @@
import 'dart:isolate'; import 'dart:isolate';
import 'package:aves/services/services.dart';
import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/aves_app.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
void main() { void main() {
@ -20,12 +20,11 @@ void main() {
// //
// flutter run --profile --trace-skia // flutter run --profile --trace-skia
Isolate.current.addErrorListener(RawReceivePort((pair) async { initPlatformServices();
Isolate.current.addErrorListener(RawReceivePort((pair) {
final List<dynamic> errorAndStacktrace = pair; final List<dynamic> errorAndStacktrace = pair;
await FirebaseCrashlytics.instance.recordError( reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
errorAndStacktrace.first,
errorAndStacktrace.last,
);
}).sendPort); }).sendPort);
runApp(const AvesApp()); runApp(const AvesApp());

View file

@ -6,6 +6,7 @@ enum ChipSetAction {
// general // general
sort, sort,
group, group,
map,
select, select,
selectAll, selectAll,
selectNone, selectNone,
@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.collectionActionSelectAll; return context.l10n.collectionActionSelectAll;
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
return context.l10n.collectionActionSelectNone; return context.l10n.collectionActionSelectNone;
case ChipSetAction.map:
return context.l10n.menuActionMap;
case ChipSetAction.stats: case ChipSetAction.stats:
return context.l10n.menuActionStats; return context.l10n.menuActionStats;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction {
case ChipSetAction.selectAll: case ChipSetAction.selectAll:
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
return null; return null;
case ChipSetAction.map:
return AIcons.map;
case ChipSetAction.stats: case ChipSetAction.stats:
return AIcons.stats; return AIcons.stats;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:

View file

@ -5,6 +5,7 @@ enum CollectionAction {
select, select,
selectAll, selectAll,
selectNone, selectNone,
map,
stats, stats,
// apply to entry set // apply to entry set
copy, copy,

View file

@ -19,6 +19,7 @@ enum EntryAction {
// motion photo, // motion photo,
viewMotionPhotoVideo, viewMotionPhotoVideo,
// external // external
copyToClipboard,
edit, edit,
open, open,
openMap, openMap,
@ -42,6 +43,7 @@ class EntryActions {
EntryAction.delete, EntryAction.delete,
EntryAction.rename, EntryAction.rename,
EntryAction.export, EntryAction.export,
EntryAction.copyToClipboard,
EntryAction.print, EntryAction.print,
EntryAction.viewSource, EntryAction.viewSource,
EntryAction.viewMotionPhotoVideo, EntryAction.viewMotionPhotoVideo,
@ -68,6 +70,8 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
// different data depending on toggle state // different data depending on toggle state
return context.l10n.entryActionAddFavourite; return context.l10n.entryActionAddFavourite;
case EntryAction.copyToClipboard:
return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete: case EntryAction.delete:
return context.l10n.entryActionDelete; return context.l10n.entryActionDelete;
case EntryAction.export: case EntryAction.export:
@ -116,6 +120,8 @@ extension ExtraEntryAction on EntryAction {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
// different data depending on toggle state // different data depending on toggle state
return AIcons.favourite; return AIcons.favourite;
case EntryAction.copyToClipboard:
return AIcons.clipboard;
case EntryAction.delete: case EntryAction.delete:
return AIcons.delete; return AIcons.delete;
case EntryAction.export: case EntryAction.export:

View file

@ -3,6 +3,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -77,10 +78,13 @@ class Covers with ChangeNotifier {
} }
@immutable @immutable
class CoverRow { class CoverRow extends Equatable {
final CollectionFilter filter; final CollectionFilter filter;
final int contentId; final int contentId;
@override
List<Object?> get props => [filter, contentId];
const CoverRow({ const CoverRow({
required this.filter, required this.filter,
required this.contentId, required this.contentId,
@ -99,16 +103,4 @@ class CoverRow {
'filter': filter.toJson(), 'filter': filter.toJson(),
'contentId': contentId, '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}';
} }

View file

@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
@ -382,13 +381,6 @@ class AvesEntry {
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null; 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<String>? _xmpSubjects; List<String>? _xmpSubjects;
List<String> get xmpSubjects { List<String> get xmpSubjects {

View file

@ -10,6 +10,8 @@ import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
extension ExtraAvesEntry on AvesEntry { extension ExtraAvesEntry on AvesEntry {
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
ThumbnailProvider getThumbnail({double extent = 0}) { ThumbnailProvider getThumbnail({double extent = 0}) {
return ThumbnailProvider(_getThumbnailProviderKey(extent)); return ThumbnailProvider(_getThumbnailProviderKey(extent));
} }

View file

@ -1,7 +1,7 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
final Favourites favourites = Favourites._private(); final Favourites favourites = Favourites._private();
@ -62,10 +62,13 @@ class Favourites with ChangeNotifier {
} }
@immutable @immutable
class FavouriteRow { class FavouriteRow extends Equatable {
final int contentId; final int contentId;
final String path; final String path;
@override
List<Object?> get props => [contentId, path];
const FavouriteRow({ const FavouriteRow({
required this.contentId, required this.contentId,
required this.path, required this.path,
@ -82,16 +85,4 @@ class FavouriteRow {
'contentId': contentId, 'contentId': contentId,
'path': path, '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}';
} }

View file

@ -16,6 +16,9 @@ class AlbumFilter extends CollectionFilter {
final String album; final String album;
final String? displayName; final String? displayName;
@override
List<Object?> get props => [album];
const AlbumFilter(this.album, this.displayName); const AlbumFilter(this.album, this.displayName);
AlbumFilter.fromMap(Map<String, dynamic> json) AlbumFilter.fromMap(Map<String, dynamic> json)
@ -78,16 +81,4 @@ class AlbumFilter extends CollectionFilter {
// key `album-{path}` is expected by test driver // key `album-{path}` is expected by test driver
@override @override
String get key => '$type-$album'; 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}';
} }

View file

@ -10,6 +10,9 @@ class FavouriteFilter extends CollectionFilter {
static const instance = FavouriteFilter._private(); static const instance = FavouriteFilter._private();
@override
List<Object?> get props => [];
const FavouriteFilter._private(); const FavouriteFilter._private();
@override @override
@ -37,13 +40,4 @@ class FavouriteFilter extends CollectionFilter {
@override @override
String get key => type; 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;
} }

View file

@ -11,10 +11,12 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart'; import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
abstract class CollectionFilter implements Comparable<CollectionFilter> { @immutable
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
static const List<String> categoryOrder = [ static const List<String> categoryOrder = [
QueryFilter.type, QueryFilter.type,
FavouriteFilter.type, FavouriteFilter.type,
@ -88,20 +90,15 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
} }
} }
class FilterGridItem<T extends CollectionFilter> { @immutable
class FilterGridItem<T extends CollectionFilter> with EquatableMixin {
final T filter; final T filter;
final AvesEntry? entry; final AvesEntry? entry;
@override
List<Object?> get props => [filter, entry?.uri];
const FilterGridItem(this.filter, this.entry); 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); typedef EntryFilter = bool Function(AvesEntry);

View file

@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class LocationFilter extends CollectionFilter { class LocationFilter extends CollectionFilter {
@ -10,14 +9,17 @@ class LocationFilter extends CollectionFilter {
static const locationSeparator = ';'; static const locationSeparator = ';';
final LocationLevel level; final LocationLevel level;
String _location; late final String _location;
String? _countryCode; late final String? _countryCode;
late EntryFilter _test; late final EntryFilter _test;
LocationFilter(this.level, this._location) { @override
final split = _location.split(locationSeparator); List<Object?> get props => [level, _location, _countryCode];
if (split.isNotEmpty) _location = split[0];
if (split.length > 1) _countryCode = split[1]; 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) { if (_location.isEmpty) {
_test = (entry) => !entry.hasGps; _test = (entry) => !entry.hasGps;
@ -75,18 +77,6 @@ class LocationFilter extends CollectionFilter {
@override @override
String get key => '$type-$level-$_location'; 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+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041; static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;

View file

@ -3,20 +3,22 @@ import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/mime_utils.dart'; import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class MimeFilter extends CollectionFilter { class MimeFilter extends CollectionFilter {
static const type = 'mime'; static const type = 'mime';
final String mime; final String mime;
late EntryFilter _test; late final EntryFilter _test;
late String _label; late final String _label;
late IconData _icon; late final IconData _icon;
static final image = MimeFilter(MimeTypes.anyImage); static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo); static final video = MimeFilter(MimeTypes.anyVideo);
@override
List<Object?> get props => [mime];
MimeFilter(this.mime) { MimeFilter(this.mime) {
IconData? icon; IconData? icon;
var lowMime = mime.toLowerCase(); var lowMime = mime.toLowerCase();
@ -73,16 +75,4 @@ class MimeFilter extends CollectionFilter {
@override @override
String get key => '$type-$mime'; 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}';
} }

View file

@ -1,12 +1,13 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class PathFilter extends CollectionFilter { class PathFilter extends CollectionFilter {
static const type = 'path'; static const type = 'path';
final String path; final String path;
@override
List<Object?> get props => [path];
const PathFilter(this.path); const PathFilter(this.path);
PathFilter.fromMap(Map<String, dynamic> json) PathFilter.fromMap(Map<String, dynamic> json)
@ -31,16 +32,4 @@ class PathFilter extends CollectionFilter {
@override @override
String get key => '$type-$path'; 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}';
} }

View file

@ -12,7 +12,10 @@ class QueryFilter extends CollectionFilter {
final String query; final String query;
final bool colorful; final bool colorful;
late EntryFilter _test; late final EntryFilter _test;
@override
List<Object?> get props => [query];
QueryFilter(this.query, {this.colorful = true}) { QueryFilter(this.query, {this.colorful = true}) {
var upQuery = query.toUpperCase(); var upQuery = query.toUpperCase();
@ -63,16 +66,4 @@ class QueryFilter extends CollectionFilter {
@override @override
String get key => '$type-$query'; 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}';
} }

View file

@ -1,14 +1,16 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class TagFilter extends CollectionFilter { class TagFilter extends CollectionFilter {
static const type = 'tag'; static const type = 'tag';
final String tag; final String tag;
late EntryFilter _test; late final EntryFilter _test;
@override
List<Object?> get props => [tag];
TagFilter(this.tag) { TagFilter(this.tag) {
if (tag.isEmpty) { if (tag.isEmpty) {
@ -49,16 +51,4 @@ class TagFilter extends CollectionFilter {
@override @override
String get key => '$type-$tag'; 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}';
} }

View file

@ -1,7 +1,6 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class TypeFilter extends CollectionFilter { class TypeFilter extends CollectionFilter {
@ -14,8 +13,8 @@ class TypeFilter extends CollectionFilter {
static const _sphericalVideo = 'spherical_video'; // subset of videos static const _sphericalVideo = 'spherical_video'; // subset of videos
final String itemType; final String itemType;
late EntryFilter _test; late final EntryFilter _test;
late IconData _icon; late final IconData _icon;
static final animated = TypeFilter._private(_animated); static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);
@ -23,12 +22,19 @@ class TypeFilter extends CollectionFilter {
static final panorama = TypeFilter._private(_panorama); static final panorama = TypeFilter._private(_panorama);
static final sphericalVideo = TypeFilter._private(_sphericalVideo); static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@override
List<Object?> get props => [itemType];
TypeFilter._private(this.itemType) { TypeFilter._private(this.itemType) {
switch (itemType) { switch (itemType) {
case _animated: case _animated:
_test = (entry) => entry.isAnimated; _test = (entry) => entry.isAnimated;
_icon = AIcons.animated; _icon = AIcons.animated;
break; break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
_icon = AIcons.geo;
break;
case _motionPhoto: case _motionPhoto:
_test = (entry) => entry.isMotionPhoto; _test = (entry) => entry.isMotionPhoto;
_icon = AIcons.motionPhoto; _icon = AIcons.motionPhoto;
@ -41,10 +47,6 @@ class TypeFilter extends CollectionFilter {
_test = (entry) => entry.isVideo && entry.is360; _test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threeSixty; _icon = AIcons.threeSixty;
break; break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
_icon = AIcons.geo;
break;
} }
} }
@ -91,16 +93,4 @@ class TypeFilter extends CollectionFilter {
@override @override
String get key => '$type-$itemType'; 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}';
} }

View file

@ -30,6 +30,8 @@ abstract class MetadataDb {
Future<void> updateEntryId(int oldId, AvesEntry entry); Future<void> updateEntryId(int oldId, AvesEntry entry);
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
// date taken // date taken
Future<void> clearDates(); Future<void> clearDates();
@ -235,6 +237,19 @@ class SqfliteMetadataDb implements MetadataDb {
); );
} }
@override
Future<Set<AvesEntry>> 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 // date taken
@override @override
@ -284,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb {
await batch.commit(noResult: true); await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries'); debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
} catch (error, stack) { } 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');
} }
} }

View file

@ -13,7 +13,6 @@ import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart'; import 'package:aves/utils/pedantic.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.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) // to allow settings customization without Firebase context (e.g. before a Flutter Driver test)
Future<void> initFirebase() async { Future<void> initFirebase() async {
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled); await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled);
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled); await reportService.setCollectionEnabled(isCrashlyticsEnabled);
} }
Future<void> reset({required bool includeInternalKeys}) async { Future<void> reset({required bool includeInternalKeys}) async {

View file

@ -29,7 +29,8 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getAlbumDisplayName(BuildContext? context, String dirPath) { String getAlbumDisplayName(BuildContext? context, String dirPath) {
assert(!dirPath.endsWith(pContext.separator)); final separator = pContext.separator;
assert(!dirPath.endsWith(separator));
if (context != null) { if (context != null) {
final type = androidFileUtils.getAlbumType(dirPath); final type = androidFileUtils.getAlbumType(dirPath);
@ -52,8 +53,9 @@ mixin AlbumMixin on SourceBase {
String unique(String dirPath, Set<String?> others) { String unique(String dirPath, Set<String?> others) {
final parts = pContext.split(dirPath); final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) { for (var i = parts.length - 1; i > 0; i--) {
final testName = pContext.joinAll(['', ...parts.skip(i)]); final name = pContext.joinAll(['', ...parts.skip(i)]);
if (others.every((item) => !item!.endsWith(testName))) return testName; final testName = '$separator$name';
if (others.every((item) => !item!.endsWith(testName))) return name;
} }
return dirPath; return dirPath;
} }

View file

@ -1,41 +1,25 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@immutable
class SectionKey { class SectionKey {
const SectionKey(); const SectionKey();
} }
class EntryAlbumSectionKey extends SectionKey { class EntryAlbumSectionKey extends SectionKey with EquatableMixin {
final String? directory; final String? directory;
@override
List<Object?> get props => [directory];
const EntryAlbumSectionKey(this.directory); const EntryAlbumSectionKey(this.directory);
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is EntryAlbumSectionKey && other.directory == directory;
} }
@override class EntryDateSectionKey extends SectionKey with EquatableMixin {
int get hashCode => directory.hashCode;
@override
String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
}
class EntryDateSectionKey extends SectionKey {
final DateTime? date; final DateTime? date;
@override
List<Object?> get props => [date];
const EntryDateSectionKey(this.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}';
} }

View file

@ -1,10 +1,12 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
class AndroidAppService { class AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app'); static const platform = MethodChannel('deckers.thibault/aves/app');
@ -20,7 +22,7 @@ class AndroidAppService {
} }
return packages; return packages;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getPackages', e);
} }
return {}; return {};
} }
@ -33,11 +35,24 @@ class AndroidAppService {
}); });
if (result != null) return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } 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); return Uint8List(0);
} }
static Future<bool> copyToClipboard(String uri, String? label) async {
try {
final result = await platform.invokeMethod('copyToClipboard', <String, dynamic>{
'uri': uri,
'label': label,
});
if (result != null) return result as bool;
} on PlatformException catch (e) {
await reportService.recordChannelError('copyToClipboard', e);
}
return false;
}
static Future<bool> edit(String uri, String mimeType) async { static Future<bool> edit(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('edit', <String, dynamic>{ final result = await platform.invokeMethod('edit', <String, dynamic>{
@ -46,7 +61,7 @@ class AndroidAppService {
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('edit', e);
} }
return false; return false;
} }
@ -59,19 +74,23 @@ class AndroidAppService {
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('open', e);
} }
return false; return false;
} }
static Future<bool> openMap(String geoUri) async { static Future<bool> 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 { try {
final result = await platform.invokeMethod('openMap', <String, dynamic>{ final result = await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri, 'geoUri': geoUri,
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('openMap', e);
} }
return false; return false;
} }
@ -84,7 +103,7 @@ class AndroidAppService {
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('setAs', e);
} }
return false; return false;
} }
@ -99,7 +118,7 @@ class AndroidAppService {
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('shareEntries', e);
} }
return false; return false;
} }
@ -113,7 +132,7 @@ class AndroidAppService {
}); });
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('shareSingle', e);
} }
return false; return false;
} }

View file

@ -1,17 +1,57 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class AndroidDebugService { class AndroidDebugService {
static const platform = MethodChannel('deckers.thibault/aves/debug'); static const platform = MethodChannel('deckers.thibault/aves/debug');
static Future<void> crash() async {
try {
await platform.invokeMethod('crash');
} on PlatformException catch (e) {
await reportService.recordChannelError('crash', e);
}
}
static Future<void> exception() async {
try {
await platform.invokeMethod('exception');
} on PlatformException catch (e) {
await reportService.recordChannelError('exception', e);
}
}
static Future<void> safeException() async {
try {
await platform.invokeMethod('safeException');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeException', e);
}
}
static Future<void> exceptionInCoroutine() async {
try {
await platform.invokeMethod('exceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('exceptionInCoroutine', e);
}
}
static Future<void> safeExceptionInCoroutine() async {
try {
await platform.invokeMethod('safeExceptionInCoroutine');
} on PlatformException catch (e) {
await reportService.recordChannelError('safeExceptionInCoroutine', e);
}
}
static Future<Map> getContextDirs() async { static Future<Map> getContextDirs() async {
try { try {
final result = await platform.invokeMethod('getContextDirs'); final result = await platform.invokeMethod('getContextDirs');
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getContextDirs', e);
} }
return {}; return {};
} }
@ -21,7 +61,7 @@ class AndroidDebugService {
final result = await platform.invokeMethod('getEnv'); final result = await platform.invokeMethod('getEnv');
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getEnv', e);
} }
return {}; return {};
} }
@ -34,7 +74,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getBitmapFactoryInfo', e);
} }
return {}; return {};
} }
@ -48,7 +88,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getContentResolverMetadata', e);
} }
return {}; return {};
} }
@ -63,7 +103,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getExifInterfaceMetadata', e);
} }
return {}; return {};
} }
@ -76,7 +116,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e);
} }
return {}; return {};
} }
@ -91,7 +131,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getMetadataExtractorSummary', e);
} }
return {}; return {};
} }
@ -105,7 +145,7 @@ class AndroidDebugService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getTiffStructure', e);
} }
return {}; return {};
} }

View file

@ -24,7 +24,7 @@ class AppShortcutService {
return result; return result;
} }
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('canPin', e);
} }
return false; return false;
} }
@ -50,7 +50,7 @@ class AppShortcutService {
'filters': filters.map((filter) => filter.toJson()).toList(), 'filters': filters.map((filter) => filter.toJson()).toList(),
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('pin', e);
} }
} }
} }

View file

@ -1,6 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class DeviceService { class DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device'); static const platform = MethodChannel('deckers.thibault/aves/device');
@ -11,7 +10,7 @@ class DeviceService {
final result = await platform.invokeMethod('getPerformanceClass'); final result = await platform.invokeMethod('getPerformanceClass');
if (result != null) return result as int; if (result != null) return result as int;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getPerformanceClass failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getPerformanceClass', e);
} }
return 0; return 0;
} }

View file

@ -1,7 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class EmbeddedDataService { abstract class EmbeddedDataService {
@ -27,7 +27,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
}); });
if (result != null) return (result as List).cast<Uint8List>(); if (result != null) return (result as List).cast<Uint8List>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getExifThumbnail', e);
} }
return []; return [];
} }
@ -43,7 +43,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('extractMotionPhotoVideo', e);
} }
return {}; return {};
} }
@ -57,7 +57,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('extractVideoEmbeddedPicture', e);
} }
return {}; return {};
} }
@ -75,7 +75,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('extractXmpDataProp', e);
} }
return {}; return {};
} }

View file

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class GeocodingService { class GeocodingService {
@ -21,7 +21,7 @@ class GeocodingService {
}); });
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList(); return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getAddress failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getAddress', e);
} }
return []; return [];
} }

View file

@ -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<void> registerCallback() async {
try {
await platform.invokeMethod('registerCallback', <String, dynamic>{
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
});
} on PlatformException catch (e) {
await reportService.recordChannelError('registerCallback', e);
}
}
}
Future<void> _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<List<Map<String, String?>>> _getSuggestions(dynamic args) async {
final suggestions = <Map<String, String?>>[];
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;
}

View file

@ -1,14 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/output_buffer.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class ImageFileService { abstract class ImageFileService {
@ -124,7 +125,7 @@ class PlatformImageFileService implements ImageFileService {
}) as Map; }) as Map;
return AvesEntry.fromMap(result); return AvesEntry.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getEntry', e);
} }
return null; return null;
} }
@ -188,7 +189,7 @@ class PlatformImageFileService implements ImageFileService {
); );
return completer.future; return completer.future;
} on PlatformException catch (e) { } 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)); return Future.sync(() => Uint8List(0));
} }
@ -223,7 +224,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } 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); return Uint8List(0);
}, },
@ -260,7 +261,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return result as Uint8List; if (result != null) return result as Uint8List;
} on PlatformException catch (e) { } 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); return Uint8List(0);
}, },
@ -274,7 +275,7 @@ class PlatformImageFileService implements ImageFileService {
try { try {
return platform.invokeMethod('clearSizedThumbnailDiskCache'); return platform.invokeMethod('clearSizedThumbnailDiskCache');
} on PlatformException catch (e) { } 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(), 'entries': entries.map(_toPlatformEntryMap).toList(),
}).map((event) => ImageOpEvent.fromMap(event)); }).map((event) => ImageOpEvent.fromMap(event));
} on PlatformException catch (e) { } 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); return Stream.error(e);
} }
} }
@ -314,7 +315,7 @@ class PlatformImageFileService implements ImageFileService {
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
}).map((event) => MoveOpEvent.fromMap(event)); }).map((event) => MoveOpEvent.fromMap(event));
} on PlatformException catch (e) { } 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); return Stream.error(e);
} }
} }
@ -333,7 +334,7 @@ class PlatformImageFileService implements ImageFileService {
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
}).map((event) => ExportOpEvent.fromMap(event)); }).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e) { } 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); return Stream.error(e);
} }
} }
@ -356,7 +357,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('captureFrame', e);
} }
return {}; return {};
} }
@ -371,7 +372,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('rename', e);
} }
return {}; return {};
} }
@ -386,7 +387,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('rotate', e);
} }
return {}; return {};
} }
@ -400,7 +401,7 @@ class PlatformImageFileService implements ImageFileService {
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('flip', e);
} }
return {}; return {};
} }

View file

@ -1,11 +1,14 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable @immutable
class ImageOpEvent { class ImageOpEvent extends Equatable {
final bool success; final bool success;
final String uri; final String uri;
@override
List<Object?> get props => [success, uri];
const ImageOpEvent({ const ImageOpEvent({
required this.success, required this.success,
required this.uri, required this.uri,
@ -17,18 +20,6 @@ class ImageOpEvent {
uri: map['uri'], 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 { class MoveOpEvent extends ImageOpEvent {
@ -55,6 +46,9 @@ class MoveOpEvent extends ImageOpEvent {
class ExportOpEvent extends MoveOpEvent { class ExportOpEvent extends MoveOpEvent {
final int? pageId; final int? pageId;
@override
List<Object?> get props => [success, uri, pageId];
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields}) const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields})
: super( : super(
success: success, success: success,
@ -70,16 +64,4 @@ class ExportOpEvent extends MoveOpEvent {
newFields: map['newFields'] ?? {}, 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}';
} }

View file

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
abstract class MediaStoreService { abstract class MediaStoreService {
@ -26,7 +26,7 @@ class PlatformMediaStoreService implements MediaStoreService {
}); });
return (result as List).cast<int>(); return (result as List).cast<int>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('checkObsoleteContentIds', e);
} }
return []; return [];
} }
@ -39,7 +39,7 @@ class PlatformMediaStoreService implements MediaStoreService {
}); });
return (result as List).cast<int>(); return (result as List).cast<int>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('checkObsoletePaths failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('checkObsoletePaths', e);
} }
return []; return [];
} }
@ -51,7 +51,7 @@ class PlatformMediaStoreService implements MediaStoreService {
'knownEntries': knownEntries, 'knownEntries': knownEntries,
}).map((event) => AvesEntry.fromMap(event)); }).map((event) => AvesEntry.fromMap(event));
} on PlatformException catch (e) { } 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); return Stream.error(e);
} }
} }

View file

@ -3,7 +3,7 @@ import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/panorama.dart'; import 'package:aves/model/panorama.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class MetadataService { abstract class MetadataService {
@ -36,7 +36,7 @@ class PlatformMetadataService implements MetadataService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getAllMetadata', e);
} }
return {}; return {};
} }
@ -66,7 +66,7 @@ class PlatformMetadataService implements MetadataService {
result['contentId'] = entry.contentId; result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result); return CatalogMetadata.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getCatalogMetadata', e);
} }
return null; return null;
} }
@ -92,7 +92,7 @@ class PlatformMetadataService implements MetadataService {
}) as Map; }) as Map;
return OverlayMetadata.fromMap(result); return OverlayMetadata.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getOverlayMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getOverlayMetadata', e);
} }
return null; return null;
} }
@ -114,7 +114,7 @@ class PlatformMetadataService implements MetadataService {
} }
return MultiPageInfo.fromPageMaps(entry, pageMaps); return MultiPageInfo.fromPageMaps(entry, pageMaps);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getMultiPageInfo', e);
} }
return null; return null;
} }
@ -132,7 +132,7 @@ class PlatformMetadataService implements MetadataService {
}) as Map; }) as Map;
return PanoramaInfo.fromMap(result); return PanoramaInfo.fromMap(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('PanoramaInfo', e);
} }
return null; return null;
} }
@ -146,7 +146,7 @@ class PlatformMetadataService implements MetadataService {
'prop': prop, 'prop': prop,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getContentResolverProp', e);
} }
return null; return null;
} }

View file

@ -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<void> setCollectionEnabled(bool enabled);
Future<void> log(String message);
Future<void> setCustomKey(String key, Object value);
Future<void> setCustomKeys(Map<String, Object> map);
Future<void> recordError(dynamic exception, StackTrace? stack);
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails);
Future<void> recordChannelError(String method, PlatformException e) {
return recordError('$method failed with code=${e.code}, exception=${e.message}, details=${e.details}}', null);
}
}
class CrashlyticsReportService extends ReportService {
FirebaseCrashlytics get instance => FirebaseCrashlytics.instance;
@override
bool get isCollectionEnabled => instance.isCrashlyticsCollectionEnabled;
@override
Future<void> setCollectionEnabled(bool enabled) => instance.setCrashlyticsCollectionEnabled(enabled);
@override
Future<void> log(String message) => instance.log(message);
@override
Future<void> setCustomKey(String key, Object value) => instance.setCustomKey(key, value);
@override
Future<void> setCustomKeys(Map<String, Object> map) {
final _instance = instance;
return Future.forEach<MapEntry<String, Object>>(map.entries, (kv) => _instance.setCustomKey(kv.key, kv.value));
}
@override
Future<void> recordError(dynamic exception, StackTrace? stack) {
return instance.recordError(exception, stack);
}
@override
Future<void> recordFlutterError(FlutterErrorDetails flutterErrorDetails) {
return instance.recordFlutterError(flutterErrorDetails);
}
}

View file

@ -4,6 +4,7 @@ import 'package:aves/services/embedded_data_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/media_store_service.dart';
import 'package:aves/services/metadata_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/storage_service.dart';
import 'package:aves/services/time_service.dart'; import 'package:aves/services/time_service.dart';
import 'package:aves/services/window_service.dart'; import 'package:aves/services/window_service.dart';
@ -20,6 +21,7 @@ final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final ImageFileService imageFileService = getIt<ImageFileService>(); final ImageFileService imageFileService = getIt<ImageFileService>();
final MediaStoreService mediaStoreService = getIt<MediaStoreService>(); final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
final MetadataService metadataService = getIt<MetadataService>(); final MetadataService metadataService = getIt<MetadataService>();
final ReportService reportService = getIt<ReportService>();
final StorageService storageService = getIt<StorageService>(); final StorageService storageService = getIt<StorageService>();
final TimeService timeService = getIt<TimeService>(); final TimeService timeService = getIt<TimeService>();
final WindowService windowService = getIt<WindowService>(); final WindowService windowService = getIt<WindowService>();
@ -33,6 +35,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService()); getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService()); getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService()); getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService()); getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService()); getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService()); getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:aves/services/output_buffer.dart'; import 'package:aves/services/output_buffer.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -47,7 +48,7 @@ class PlatformStorageService implements StorageService {
final result = await platform.invokeMethod('getStorageVolumes'); final result = await platform.invokeMethod('getStorageVolumes');
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet(); return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getStorageVolumes', e);
} }
return {}; return {};
} }
@ -60,7 +61,7 @@ class PlatformStorageService implements StorageService {
}); });
return result as int?; return result as int?;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getFreeSpace', e);
} }
return null; return null;
} }
@ -71,7 +72,7 @@ class PlatformStorageService implements StorageService {
final result = await platform.invokeMethod('getGrantedDirectories'); final result = await platform.invokeMethod('getGrantedDirectories');
return (result as List).cast<String>(); return (result as List).cast<String>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getGrantedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getGrantedDirectories', e);
} }
return []; return [];
} }
@ -83,7 +84,7 @@ class PlatformStorageService implements StorageService {
'path': path, 'path': path,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('revokeDirectoryAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('revokeDirectoryAccess', e);
} }
return; return;
} }
@ -98,7 +99,7 @@ class PlatformStorageService implements StorageService {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet(); return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
} }
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getInaccessibleDirectories', e);
} }
return {}; return {};
} }
@ -111,7 +112,7 @@ class PlatformStorageService implements StorageService {
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet(); return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
} }
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('getRestrictedDirectories', e);
} }
return {}; return {};
} }
@ -134,7 +135,7 @@ class PlatformStorageService implements StorageService {
); );
return completer.future; return completer.future;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('requestVolumeAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('requestVolumeAccess', e);
} }
return false; return false;
} }
@ -148,7 +149,7 @@ class PlatformStorageService implements StorageService {
}); });
if (result != null) return result as int; if (result != null) return result as int;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('deleteEmptyDirectories', e);
} }
return 0; return 0;
} }
@ -164,7 +165,7 @@ class PlatformStorageService implements StorageService {
}); });
if (result != null) return Uri.tryParse(result); if (result != null) return Uri.tryParse(result);
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('scanFile', e);
} }
return null; return null;
} }
@ -172,7 +173,7 @@ class PlatformStorageService implements StorageService {
@override @override
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async { Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
try { try {
final completer = Completer<bool>(); final completer = Completer<bool?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'createFile', 'op': 'createFile',
'name': name, 'name': name,
@ -188,7 +189,7 @@ class PlatformStorageService implements StorageService {
); );
return completer.future; return completer.future;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('createFile', e);
} }
return false; return false;
} }
@ -215,7 +216,7 @@ class PlatformStorageService implements StorageService {
); );
return completer.future; return completer.future;
} on PlatformException catch (e) { } 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); return Uint8List(0);
} }
@ -223,7 +224,7 @@ class PlatformStorageService implements StorageService {
@override @override
Future<String?> selectDirectory() async { Future<String?> selectDirectory() async {
try { try {
final completer = Completer<String>(); final completer = Completer<String?>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'selectDirectory', 'op': 'selectDirectory',
}).listen( }).listen(
@ -236,7 +237,7 @@ class PlatformStorageService implements StorageService {
); );
return completer.future; return completer.future;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); await reportService.recordChannelError('selectDirectory', e);
} }
return null; return null;
} }

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
abstract class TimeService { abstract class TimeService {
@ -13,7 +13,7 @@ class PlatformTimeService implements TimeService {
try { try {
return await platform.invokeMethod('getDefaultTimeZone'); return await platform.invokeMethod('getDefaultTimeZone');
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getDefaultTimeZone', e);
} }
return null; return null;
} }

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class ViewerService { class ViewerService {
@ -10,7 +10,7 @@ class ViewerService {
final result = await platform.invokeMethod('getIntentData'); final result = await platform.invokeMethod('getIntentData');
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('getIntentData', e);
} }
return {}; return {};
} }
@ -21,7 +21,7 @@ class ViewerService {
'uri': uri, 'uri': uri,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('pick failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('pick', e);
} }
} }
} }

View file

@ -1,4 +1,4 @@
import 'package:flutter/foundation.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -24,7 +24,7 @@ class PlatformWindowService implements WindowService {
'on': on, 'on': on,
}); });
} on PlatformException catch (e) { } 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'); final result = await platform.invokeMethod('isRotationLocked');
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('isRotationLocked failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('isRotationLocked', e);
} }
return false; return false;
} }
@ -62,7 +62,7 @@ class PlatformWindowService implements WindowService {
'orientation': orientationCode, 'orientation': orientationCode,
}); });
} on PlatformException catch (e) { } 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'); final result = await platform.invokeMethod('canSetCutoutMode');
if (result != null) return result as bool; if (result != null) return result as bool;
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('canSetCutoutMode', e);
} }
return false; return false;
} }
@ -84,7 +84,7 @@ class PlatformWindowService implements WindowService {
'use': use, 'use': use,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}'); await reportService.recordChannelError('setCutoutMode', e);
} }
} }
} }

View file

@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart';
class Durations { class Durations {
// Flutter animations (with margin) // Flutter animations (with margin)
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute` 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 dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState` static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin` static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`

View file

@ -36,6 +36,7 @@ class AIcons {
static const IconData skip10 = Icons.forward_10_outlined; static const IconData skip10 = Icons.forward_10_outlined;
static const IconData captureFrame = Icons.screenshot_outlined; static const IconData captureFrame = Icons.screenshot_outlined;
static const IconData clear = Icons.clear_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 createAlbum = Icons.add_circle_outline;
static const IconData debug = Icons.whatshot_outlined; static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined; static const IconData delete = Icons.delete_outlined;
@ -49,6 +50,7 @@ class AIcons {
static const IconData import = MdiIcons.fileImportOutline; static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined; static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined; static const IconData layers = Icons.layers_outlined;
static const IconData map = Icons.map_outlined;
static const IconData newTier = Icons.fiber_new_outlined; static const IconData newTier = Icons.fiber_new_outlined;
static const IconData openOutside = Icons.open_in_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined; static const IconData pin = Icons.push_pin_outlined;

View file

@ -3,13 +3,14 @@ import 'package:aves/services/services.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
class AndroidFileUtils { class AndroidFileUtils {
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
Set<StorageVolume> storageVolumes = {}; Set<StorageVolume> storageVolumes = {};
Set<Package> _packages = {}; Set<Package> _packages = {};
List<String> _potentialAppDirs = []; List<String> _potentialAppDirs = [];
@ -21,9 +22,10 @@ class AndroidFileUtils {
AndroidFileUtils._private(); AndroidFileUtils._private();
Future<void> init() async { Future<void> init() async {
separator = pContext.separator;
storageVolumes = await storageService.getStorageVolumes(); storageVolumes = await storageService.getStorageVolumes();
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator;
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? '/'; // standard
dcimPath = pContext.join(primaryStorage, 'DCIM'); dcimPath = pContext.join(primaryStorage, 'DCIM');
downloadPath = pContext.join(primaryStorage, 'Download'); downloadPath = pContext.join(primaryStorage, 'Download');
moviesPath = pContext.join(primaryStorage, 'Movies'); moviesPath = pContext.join(primaryStorage, 'Movies');
@ -38,11 +40,11 @@ class AndroidFileUtils {
appNameChangeNotifier.notifyListeners(); 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; bool isVideoCapturesPath(String path) => path == videoCapturesPath;
@ -53,7 +55,7 @@ class AndroidFileUtils {
final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path)); final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path));
// storage volume path includes trailing '/', but argument path may or may not, // storage volume path includes trailing '/', but argument path may or may not,
// which is an issue when the path is at the root // 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; bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
@ -155,9 +157,12 @@ class StorageVolume {
} }
@immutable @immutable
class VolumeRelativeDirectory { class VolumeRelativeDirectory extends Equatable {
final String volumePath, relativeDir; final String volumePath, relativeDir;
@override
List<Object?> get props => [volumePath, relativeDir];
const VolumeRelativeDirectory({ const VolumeRelativeDirectory({
required this.volumePath, required this.volumePath,
required this.relativeDir, required this.relativeDir,
@ -187,13 +192,4 @@ class VolumeRelativeDirectory {
final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath); final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath);
return volume?.getDescription(context) ?? 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);
} }

View file

@ -44,7 +44,6 @@ class Constants {
Dependency( Dependency(
name: 'AndroidSVG', name: 'AndroidSVG',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/BigBadaboom/androidsvg/blob/master/LICENSE',
sourceUrl: 'https://github.com/BigBadaboom/androidsvg', sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
), ),
Dependency( Dependency(
@ -56,19 +55,16 @@ class Constants {
Dependency( Dependency(
name: 'CWAC-Document', name: 'CWAC-Document',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/commonsguy/cwac-document/blob/master/LICENSE',
sourceUrl: 'https://github.com/commonsguy/cwac-document', sourceUrl: 'https://github.com/commonsguy/cwac-document',
), ),
Dependency( Dependency(
name: 'Glide', name: 'Glide',
license: 'Apache 2.0, BSD 2-Clause', license: 'Apache 2.0, BSD 2-Clause',
licenseUrl: 'https://github.com/bumptech/glide/blob/master/LICENSE',
sourceUrl: 'https://github.com/bumptech/glide', sourceUrl: 'https://github.com/bumptech/glide',
), ),
Dependency( Dependency(
name: 'Metadata Extractor', name: 'Metadata Extractor',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/drewnoakes/metadata-extractor/blob/master/LICENSE',
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor', sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
), ),
]; ];
@ -77,49 +73,44 @@ class Constants {
Dependency( Dependency(
name: 'Connectivity Plus', name: 'Connectivity Plus',
license: 'BSD 3-Clause', 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', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
), ),
Dependency( Dependency(
name: 'FlutterFire (Core, Crashlytics)', name: 'FlutterFire (Core, Crashlytics)',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire', sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
), ),
Dependency( Dependency(
name: 'fijkplayer (Aves fork)', name: 'fijkplayer (Aves fork)',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/fijkplayer', sourceUrl: 'https://github.com/deckerst/fijkplayer',
), ),
Dependency( Dependency(
name: 'Google API Availability', name: 'Google API Availability',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/Baseflow/flutter-google-api-availability/blob/master/LICENSE',
sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability', sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability',
), ),
Dependency( Dependency(
name: 'Google Maps for Flutter', name: 'Google Maps for Flutter',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', 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( Dependency(
name: 'Package Info Plus', name: 'Package Info Plus',
license: 'BSD 3-Clause', 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', sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
), ),
Dependency( Dependency(
name: 'Permission Handler', name: 'Permission Handler',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
), ),
Dependency( Dependency(
name: 'Printing', name: 'Printing',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
sourceUrl: 'https://github.com/DavBfr/dart_pdf', sourceUrl: 'https://github.com/DavBfr/dart_pdf',
), ),
Dependency( Dependency(
@ -130,21 +121,19 @@ class Constants {
), ),
Dependency( Dependency(
name: 'sqflite', name: 'sqflite',
license: 'MIT', license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE',
sourceUrl: 'https://github.com/tekartik/sqflite', sourceUrl: 'https://github.com/tekartik/sqflite',
), ),
Dependency( Dependency(
name: 'Streams Channel (Aves fork)', name: 'Streams Channel (Aves fork)',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/deckerst/aves_streams_channel/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/aves_streams_channel', sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
), ),
Dependency( Dependency(
name: 'URL Launcher', name: 'URL Launcher',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', 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( Dependency(
name: 'Charts', name: 'Charts',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE',
sourceUrl: 'https://github.com/google/charts', sourceUrl: 'https://github.com/google/charts',
), ),
Dependency(
name: 'Custom rounded rectangle border',
license: 'MIT',
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
),
Dependency( Dependency(
name: 'Decorated Icon', name: 'Decorated Icon',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon', sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
), ),
Dependency( Dependency(
name: 'Expansion Tile Card (Aves fork)', name: 'Expansion Tile Card (Aves fork)',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
sourceUrl: 'https://github.com/deckerst/expansion_tile_card', sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
), ),
Dependency( Dependency(
name: 'FlexColorPicker', name: 'FlexColorPicker',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/rydmike/flex_color_picker/blob/master/LICENSE',
sourceUrl: 'https://github.com/rydmike/flex_color_picker', sourceUrl: 'https://github.com/rydmike/flex_color_picker',
), ),
Dependency( Dependency(
name: 'Flutter Highlight', name: 'Flutter Highlight',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
sourceUrl: 'https://github.com/git-touch/highlight', sourceUrl: 'https://github.com/git-touch/highlight',
), ),
Dependency( Dependency(
name: 'Flutter Map', name: 'Flutter Map',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
sourceUrl: 'https://github.com/fleaflet/flutter_map', sourceUrl: 'https://github.com/fleaflet/flutter_map',
), ),
Dependency( Dependency(
@ -194,19 +182,16 @@ class Constants {
Dependency( Dependency(
name: 'Flutter Staggered Animations', name: 'Flutter Staggered Animations',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE',
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations', sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
), ),
Dependency( Dependency(
name: 'Material Design Icons Flutter', name: 'Material Design Icons Flutter',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE',
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter', sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
), ),
Dependency( Dependency(
name: 'Overlay Support', name: 'Overlay Support',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
sourceUrl: 'https://github.com/boyan01/overlay_support', sourceUrl: 'https://github.com/boyan01/overlay_support',
), ),
Dependency( Dependency(
@ -218,19 +203,16 @@ class Constants {
Dependency( Dependency(
name: 'Panorama', name: 'Panorama',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
sourceUrl: 'https://github.com/zesage/panorama', sourceUrl: 'https://github.com/zesage/panorama',
), ),
Dependency( Dependency(
name: 'Percent Indicator', name: 'Percent Indicator',
license: 'BSD 2-Clause', 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( Dependency(
name: 'Provider', name: 'Provider',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
sourceUrl: 'https://github.com/rrousselGit/provider', sourceUrl: 'https://github.com/rrousselGit/provider',
), ),
]; ];
@ -239,21 +221,28 @@ class Constants {
Dependency( Dependency(
name: 'Collection', name: 'Collection',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/collection', sourceUrl: 'https://github.com/dart-lang/collection',
), ),
Dependency( Dependency(
name: 'Country Code', name: 'Country Code',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE',
sourceUrl: 'https://github.com/denixport/dart.country', sourceUrl: 'https://github.com/denixport/dart.country',
), ),
Dependency(
name: 'Equatable',
license: 'MIT',
sourceUrl: 'https://github.com/felangel/equatable',
),
Dependency( Dependency(
name: 'Event Bus', name: 'Event Bus',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
sourceUrl: 'https://github.com/marcojakob/dart-event-bus', sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
), ),
Dependency(
name: 'Fluster',
license: 'MIT',
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
),
Dependency( Dependency(
name: 'Flutter Lints', name: 'Flutter Lints',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
@ -263,49 +252,41 @@ class Constants {
Dependency( Dependency(
name: 'Get It', name: 'Get It',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE',
sourceUrl: 'https://github.com/fluttercommunity/get_it', sourceUrl: 'https://github.com/fluttercommunity/get_it',
), ),
Dependency( Dependency(
name: 'Github', name: 'Github',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE',
sourceUrl: 'https://github.com/SpinlockLabs/github.dart', sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
), ),
Dependency( Dependency(
name: 'Intl', name: 'Intl',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/intl', sourceUrl: 'https://github.com/dart-lang/intl',
), ),
Dependency( Dependency(
name: 'LatLong2', name: 'LatLong2',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
sourceUrl: 'https://github.com/jifalops/dart-latlong', sourceUrl: 'https://github.com/jifalops/dart-latlong',
), ),
Dependency( Dependency(
name: 'PDF for Dart and Flutter', name: 'PDF for Dart and Flutter',
license: 'Apache 2.0', license: 'Apache 2.0',
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
sourceUrl: 'https://github.com/DavBfr/dart_pdf', sourceUrl: 'https://github.com/DavBfr/dart_pdf',
), ),
Dependency( Dependency(
name: 'Tuple', name: 'Tuple',
license: 'BSD 2-Clause', license: 'BSD 2-Clause',
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
sourceUrl: 'https://github.com/dart-lang/tuple', sourceUrl: 'https://github.com/dart-lang/tuple',
), ),
Dependency( Dependency(
name: 'Version', name: 'Version',
license: 'BSD 3-Clause', license: 'BSD 3-Clause',
licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE',
sourceUrl: 'https://github.com/dartninja/version', sourceUrl: 'https://github.com/dartninja/version',
), ),
Dependency( Dependency(
name: 'XML', name: 'XML',
license: 'MIT', license: 'MIT',
licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE',
sourceUrl: 'https://github.com/renggli/dart-xml', sourceUrl: 'https://github.com/renggli/dart-xml',
), ),
]; ];
@ -320,7 +301,7 @@ class Dependency {
const Dependency({ const Dependency({
required this.name, required this.name,
required this.license, required this.license,
required this.licenseUrl, String? licenseUrl,
required this.sourceUrl, required this.sourceUrl,
}); }) : licenseUrl = licenseUrl ?? '$sourceUrl/blob/master/LICENSE';
} }

View file

@ -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/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:equatable/equatable.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -43,6 +43,7 @@ class _AvesAppState extends State<AvesApp> {
List<NavigatorObserver> _navigatorObservers = []; List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange'); final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage(); Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
@ -50,10 +51,11 @@ class _AvesAppState extends State<AvesApp> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
initPlatformServices(); EquatableConfig.stringify = true;
_appSetup = _setup(); _appSetup = _setup();
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
} }
@override @override
@ -122,18 +124,17 @@ class _AvesAppState extends State<AvesApp> {
Future<void> _setup() async { Future<void> _setup() async {
await Firebase.initializeApp().then((app) { await Firebase.initializeApp().then((app) {
final crashlytics = FirebaseCrashlytics.instance; FlutterError.onError = reportService.recordFlutterError;
FlutterError.onError = crashlytics.recordFlutterError;
crashlytics.setCustomKey('locales', window.locales.join(', '));
final now = DateTime.now(); final now = DateTime.now();
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})'); reportService.setCustomKeys({
crashlytics.setCustomKey( 'locales': window.locales.join(', '),
'build_mode', 'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
kReleaseMode 'build_mode': kReleaseMode
? 'release' ? 'release'
: kProfileMode : kProfileMode
? 'profile' ? 'profile'
: 'debug'); : 'debug',
});
}); });
await settings.init(); await settings.init();
await settings.initFirebase(); await settings.initFirebase();
@ -148,7 +149,7 @@ class _AvesAppState extends State<AvesApp> {
// do not reset when relaunching the app // do not reset when relaunching the app
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return; if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
FirebaseCrashlytics.instance.log('New intent'); reportService.log('New intent');
_navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute( _navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute(
settings: const RouteSettings(name: HomePage.routeName), settings: const RouteSettings(name: HomePage.routeName),
builder: (_) => getFirstPage(intentData: intentData), builder: (_) => getFirstPage(intentData: intentData),
@ -169,4 +170,6 @@ class _AvesAppState extends State<AvesApp> {
}); });
} }
} }
void _onError(String? error) => reportService.recordError(error, null);
} }

View file

@ -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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_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_button.dart';
import 'package:aves/widgets/search/search_delegate.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -212,6 +213,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
enabled: isNotEmpty, enabled: isNotEmpty,
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select), 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( PopupMenuItem(
value: CollectionAction.stats, value: CollectionAction.stats,
enabled: isNotEmpty, enabled: isNotEmpty,
@ -292,6 +298,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case CollectionAction.selectNone: case CollectionAction.selectNone:
context.read<Selection<AvesEntry>>().clearSelection(); context.read<Selection<AvesEntry>>().clearSelection();
break; break;
case CollectionAction.map:
_goToMap();
break;
case CollectionAction.stats: case CollectionAction.stats:
_goToStats(); _goToStats();
break; break;
@ -377,6 +386,19 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
); );
} }
void _goToMap() {
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage(
source: source,
parentCollection: collection,
),
),
);
}
void _goToStats() { void _goToStats() {
Navigator.push( Navigator.push(
context, context,

View file

@ -19,6 +19,7 @@ import 'package:provider/provider.dart';
class ThumbnailImage extends StatefulWidget { class ThumbnailImage extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
final double extent; final double extent;
final bool progressive;
final BoxFit? fit; final BoxFit? fit;
final bool showLoadingBackground; final bool showLoadingBackground;
final ValueNotifier<bool>? cancellableNotifier; final ValueNotifier<bool>? cancellableNotifier;
@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget {
Key? key, Key? key,
required this.entry, required this.entry,
required this.extent, required this.extent,
this.progressive = true,
this.fit, this.fit,
this.showLoadingBackground = true, this.showLoadingBackground = true,
this.cancellableNotifier, this.cancellableNotifier,
@ -93,7 +95,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
_lastException = null; _lastException = null;
_providers.clear(); _providers.clear();
_providers.addAll([ _providers.addAll([
if (!entry.isSvg) if (widget.progressive && !entry.isSvg)
_ConditionalImageProvider( _ConditionalImageProvider(
ScrollAwareImageProvider( ScrollAwareImageProvider(
context: _scrollAwareContext, context: _scrollAwareContext,

View file

@ -72,6 +72,9 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
Stream<T> get opStream => widget.opStream; Stream<T> get opStream => widget.opStream;
static const radius = 160.0;
static const strokeWidth = 16.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -104,6 +107,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progressColor = Theme.of(context).accentColor;
return AbsorbPointer( return AbsorbPointer(
child: StreamBuilder<T>( child: StreamBuilder<T>(
stream: opStream, stream: opStream,
@ -124,20 +128,34 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
), ),
), ),
child: Center( child: Center(
child: CircularPercentIndicator( 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, percent: percent,
lineWidth: 16, lineWidth: strokeWidth,
radius: 160, radius: radius,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
progressColor: Theme.of(context).accentColor, progressColor: progressColor,
animation: true, animation: true,
center: Text(NumberFormat.percentPattern().format(percent)), center: Text(NumberFormat.percentPattern().format(percent)),
animateFromLastPercent: true, animateFromLastPercent: true,
), ),
],
),
), ),
), ),
); );
}), },
),
); );
} }
} }

View file

@ -1,18 +1,18 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:aves/services/services.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CrashlyticsRouteTracker extends NavigatorObserver { class CrashlyticsRouteTracker extends NavigatorObserver {
@override @override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPush to ${_name(route)}'); void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didPush to ${_name(route)}');
@override @override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didPop to ${_name(previousRoute)}'); void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didPop to ${_name(previousRoute)}');
@override @override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => FirebaseCrashlytics.instance.log('Nav didRemove to ${_name(previousRoute)}'); void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) => reportService.log('Nav didRemove to ${_name(previousRoute)}');
@override @override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => FirebaseCrashlytics.instance.log('Nav didReplace to ${_name(newRoute)}'); void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) => reportService.log('Nav didReplace to ${_name(newRoute)}');
String _name(Route<dynamic>? route) => route?.settings.name ?? 'unnamed ${route?.runtimeType}'; String _name(Route<dynamic>? route) => route?.settings.name ?? 'unnamed ${route?.runtimeType}';
} }

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -221,13 +222,17 @@ class SectionedListLayout<T> {
String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}'; String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}';
} }
class SectionLayout { @immutable
class SectionLayout extends Equatable {
final SectionKey sectionKey; final SectionKey sectionKey;
final int firstIndex, lastIndex, bodyFirstIndex; final int firstIndex, lastIndex, bodyFirstIndex;
final double minOffset, maxOffset, bodyMinOffset; final double minOffset, maxOffset, bodyMinOffset;
final double headerExtent, tileExtent, spacing, mainAxisStride; final double headerExtent, tileExtent, spacing, mainAxisStride;
final IndexedWidgetBuilder builder; final IndexedWidgetBuilder builder;
@override
List<Object?> get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing];
const SectionLayout({ const SectionLayout({
required this.sectionKey, required this.sectionKey,
required this.firstIndex, required this.firstIndex,
@ -263,15 +268,6 @@ class SectionLayout {
if (scrollOffset < 0) return firstIndex; if (scrollOffset < 0) return firstIndex;
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; 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 { class _GridRow extends MultiChildRenderObjectWidget {

View file

@ -1,28 +1,22 @@
import 'dart:ui'; import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@immutable @immutable
class MagnifierState { class MagnifierState extends Equatable {
const MagnifierState({
required this.position,
required this.scale,
required this.source,
});
final Offset position; final Offset position;
final double? scale; final double? scale;
final ChangeSource source; final ChangeSource source;
@override @override
bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale; List<Object?> get props => [position, scale, source];
@override const MagnifierState({
int get hashCode => hashValues(position, scale, source); required this.position,
required this.scale,
@override required this.source,
String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}'; });
} }
enum ChangeSource { internal, gesture, animation } enum ChangeSource { internal, gesture, animation }

View file

@ -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/pan/corner_hit_detector.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
import 'package:aves/widgets/common/magnifier/scale/state.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Internal widget in which controls all animations lifecycle, core responses /// Internal widget in which controls all animations lifecycle, core responses
@ -276,17 +277,21 @@ class _MagnifierCoreState extends State<MagnifierCore> with TickerProviderStateM
} }
} }
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { @immutable
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate with EquatableMixin {
final Size subjectSize;
final Alignment basePosition;
final bool applyScale;
@override
List<Object?> get props => [subjectSize, basePosition, applyScale];
const _CenterWithOriginalSizeDelegate( const _CenterWithOriginalSizeDelegate(
this.subjectSize, this.subjectSize,
this.basePosition, this.basePosition,
this.applyScale, this.applyScale,
); );
final Size subjectSize;
final Alignment basePosition;
final bool applyScale;
@override @override
Offset getPositionForChild(Size size, Size childSize) { Offset getPositionForChild(Size size, Size childSize) {
final childWidth = applyScale ? subjectSize.width : childSize.width; final childWidth = applyScale ? subjectSize.width : childSize.width;
@ -309,10 +314,4 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) { bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
return oldDelegate != this; 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);
} }

View file

@ -2,17 +2,22 @@ import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart';
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Internal class to wrap custom scale boundaries (min, max and initial) /// Internal class to wrap custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and the child. /// Also, stores values regarding the two sizes: the container and the child.
class ScaleBoundaries { @immutable
class ScaleBoundaries extends Equatable {
final ScaleLevel _minScale; final ScaleLevel _minScale;
final ScaleLevel _maxScale; final ScaleLevel _maxScale;
final ScaleLevel _initialScale; final ScaleLevel _initialScale;
final Size viewportSize; final Size viewportSize;
final Size childSize; final Size childSize;
@override
List<Object?> get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize];
const ScaleBoundaries({ const ScaleBoundaries({
required ScaleLevel minScale, required ScaleLevel minScale,
required ScaleLevel maxScale, required ScaleLevel maxScale,
@ -57,13 +62,4 @@ class ScaleBoundaries {
Offset childToStatePosition(double scale, Offset childPosition) { Offset childToStatePosition(double scale, Offset childPosition) {
return (_childCenter - childPosition) * scale; 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}';
} }

View file

@ -1,12 +1,17 @@
import 'dart:math'; import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
class ScaleLevel { @immutable
class ScaleLevel extends Equatable {
final ScaleReference ref; final ScaleReference ref;
final double factor; final double factor;
@override
List<Object?> get props => [ref, factor];
const ScaleLevel({ const ScaleLevel({
this.ref = ScaleReference.absolute, this.ref = ScaleReference.absolute,
this.factor = 1.0, 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 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); 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 } enum ScaleReference { absolute, contained, covered }

View file

@ -1,29 +1,24 @@
import 'dart:ui'; import 'dart:ui';
import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@immutable @immutable
class ScaleStateChange { class ScaleStateChange extends Equatable {
const ScaleStateChange({
required this.state,
required this.source,
this.childFocalPoint,
});
final ScaleState state; final ScaleState state;
final ChangeSource source; final ChangeSource source;
final Offset? childFocalPoint; final Offset? childFocalPoint;
@override @override
bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint; List<Object?> get props => [state, source, childFocalPoint];
@override const ScaleStateChange({
int get hashCode => hashValues(state, source, childFocalPoint); required this.state,
required this.source,
@override this.childFocalPoint,
String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}'; });
} }
enum ScaleState { enum ScaleState {

View file

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

View file

@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:latlong2/latlong.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!,
],
),
),
),
);
}
}
class MapButtonPanel extends StatelessWidget { class MapButtonPanel extends StatelessWidget {
final String geoUri; final LatLng latLng;
final void Function(double amount) zoomBy; final Future<void> Function(double amount)? zoomBy;
static const double padding = 4; static const double padding = 4;
const MapButtonPanel({ const MapButtonPanel({
Key? key, Key? key,
required this.geoUri, required this.latLng,
required this.zoomBy, this.zoomBy,
}) : super(key: key); }) : super(key: key);
@override @override
@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget {
children: [ children: [
MapOverlayButton( MapOverlayButton(
icon: AIcons.openOutside, icon: AIcons.openOutside,
onPressed: () => AndroidAppService.openMap(geoUri).then((success) { onPressed: () => AndroidAppService.openMap(latLng).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}), }),
tooltip: context.l10n.entryActionOpenMap, tooltip: context.l10n.entryActionOpenMap,
@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget {
const Spacer(), const Spacer(),
MapOverlayButton( MapOverlayButton(
icon: AIcons.zoomIn, icon: AIcons.zoomIn,
onPressed: () => zoomBy(1), onPressed: zoomBy != null ? () => zoomBy!(1) : null,
tooltip: context.l10n.viewerInfoMapZoomInTooltip, tooltip: context.l10n.viewerInfoMapZoomInTooltip,
), ),
const SizedBox(height: padding), const SizedBox(height: padding),
MapOverlayButton( MapOverlayButton(
icon: AIcons.zoomOut, icon: AIcons.zoomOut,
onPressed: () => zoomBy(-1), onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
tooltip: context.l10n.viewerInfoMapZoomOutTooltip, tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
), ),
], ],
@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget {
class MapOverlayButton extends StatelessWidget { class MapOverlayButton extends StatelessWidget {
final IconData icon; final IconData icon;
final String tooltip; final String tooltip;
final VoidCallback onPressed; final VoidCallback? onPressed;
const MapOverlayButton({ const MapOverlayButton({
Key? key, Key? key,

View file

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

View file

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

View file

@ -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<AvesEntry> entries;
final bool interactive;
final double? mapHeight;
final ValueNotifier<bool> 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<GeoMap> 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<ZoomedBounds> boundsNotifier;
List<AvesEntry> 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<GeoEntry>(
// we keep clustering on the whole range of zooms (including the maximum)
// to avoid collocated entries overlapping
minZoom: 0,
maxZoom: 22,
// TODO TLAD [map] derive `radius` / `extent`, from device pixel ratio and marker extent?
// (radius=120, extent=2 << 8) is equivalent to (radius=240, extent=2 << 9)
radius: 240,
extent: 2 << 9,
nodeSize: 64,
points: markers,
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
);
return FutureBuilder<bool>(
future: availability.isConnected,
builder: (context, snapshot) {
if (snapshot.data != true) return const SizedBox();
return Selector<Settings, EntryMapStyle>(
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<bool>(
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<Object?> get props => [entry, count];
const MarkerKey(this.entry, this.count);
}
typedef EntryMarkerBuilder = Widget Function(MarkerKey key);
typedef UserZoomChangeCallback = void Function(double zoom);

View file

@ -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<ZoomedBounds> boundsNotifier;
final bool interactive;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> 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<StatefulWidget> createState() => _EntryGoogleMapState();
}
class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObserver {
GoogleMapController? _controller;
final Map<MarkerKey, Uint8List> _markerBitmaps = {};
final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier();
ValueNotifier<ZoomedBounds> 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<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MarkerGeneratorWidget<MarkerKey>(
markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(),
isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent),
onRendered: (key, bitmap) {
_markerBitmaps[key] = bitmap;
_markerBitmapChangeNotifier.notifyListeners();
},
),
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> clusterByMarkerKey) {
return AnimatedBuilder(
animation: _markerBitmapChangeNotifier,
builder: (context, child) {
final markers = <Marker>{};
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<void> _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<void> _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;
}
}
}

View file

@ -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<T extends Key> extends StatefulWidget {
final List<Widget> 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<T>();
}
class _MarkerGeneratorWidgetState<T extends Key> extends State<MarkerGeneratorWidget<T>> {
final Set<_MarkerGeneratorItem<T>> _items = {};
@override
void initState() {
super.initState();
_checkNextFrame();
}
@override
void didUpdateWidget(covariant MarkerGeneratorWidget<T> 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<MediaQueryData, double>((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<T extends Key> {
final T markerKey;
GlobalKey? globalKey;
MarkerGeneratorItemState state = MarkerGeneratorItemState.waiting;
_MarkerGeneratorItem(this.markerKey);
bool get isWaiting => state == MarkerGeneratorItemState.waiting;
Future<Uint8List?> 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}';
}

View file

@ -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<LatLng?> {
LatLngTween({
required LatLng? begin,
required LatLng? end,
}) : super(
begin: begin,
end: end,
);
@override
LatLng? lerp(double t) => LatLngUtils.lerp(begin, end, t);
}

View file

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

View file

@ -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<ZoomedBounds> boundsNotifier;
final bool interactive;
final EntryMapStyle style;
final EntryMarkerBuilder markerBuilder;
final Fluster<GeoEntry> markerCluster;
final List<AvesEntry> 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<StatefulWidget> createState() => _EntryLeafletMapState();
}
class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderStateMixin {
final MapController _mapController = MapController();
final List<StreamSubscription> _subscriptions = [];
ValueNotifier<ZoomedBounds> 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<ZoomedBounds?>(
valueListenable: boundsNotifier,
builder: (context, visibleRegion, child) {
final allEntries = widget.markerEntries;
final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : <GeoEntry>[];
final clusterByMarkerKey = Map.fromEntries(clusters.map((v) {
if (v.isCluster!) {
final uri = v.childMarkerId;
final entry = allEntries.firstWhere((v) => v.uri == uri);
return MapEntry(MarkerKey(entry, v.pointsSize), v);
}
return MapEntry(MarkerKey(v.entry!, null), v);
}));
return Stack(
children: [
MapDecorator(
interactive: widget.interactive,
child: _buildMap(clusterByMarkerKey),
),
MapButtonPanel(
latLng: bounds.center,
zoomBy: _zoomBy,
),
],
);
},
);
}
Widget _buildMap(Map<MarkerKey, GeoEntry> 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<void> _zoomBy(double amount) async {
final endZoom = (_mapController.zoom + amount).clamp(_zoomMin, _zoomMax);
widget.onUserZoomChange?.call(endZoom);
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
await _animateCamera((animation) => _mapController.move(_mapController.center, zoomTween.evaluate(animation)));
}
Future<void> _moveTo(LatLng point) async {
final centerTween = LatLngTween(begin: _mapController.center, end: point);
await _animateCamera((animation) => _mapController.move(centerTween.evaluate(animation)!, _mapController.zoom));
}
Future<void> _animateCamera(void Function(Animation<double> 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();
}
}

View file

@ -1,12 +1,11 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/widgets/common/basic/outlined_text.dart'; 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/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart'; import 'package:flutter_map/plugin_api.dart';
import 'scalebar_utils.dart' as util;
class ScaleLayerOptions extends LayerOptions { class ScaleLayerOptions extends LayerOptions {
final Widget Function(double width, String distance) builder; 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 { class ScaleLayerWidget extends StatelessWidget {
final ScaleLayerOptions options; final ScaleLayerOptions options;
@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget {
: 2); : 2);
final distance = scale[max(0, min(20, level))].toDouble(); final distance = scale[max(0, min(20, level))].toDouble();
final start = map.project(center); 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 end = map.project(targetPoint);
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m'; final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
final width = end.x - (start.x as double); final width = end.x - (start.x as double);

View file

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

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